diff --git a/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs index c948aedb8..80c751518 100644 --- a/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs +++ b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs @@ -16,7 +16,7 @@ public static partial class AutoRefreshFixture public class WithPropertyAccessor : Base { - [Fact(Skip = "Existing defect: propertyAccessor is not null checked, throws NRE on first notification, instead")] + [Fact] public void PropertyAccessorIsNull_ThrowsException() => FluentActions.Invoking(() => ObservableCacheEx.AutoRefresh( source: Observable.Never>(), diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs index a09e0ea9c..3cee9b635 100644 --- a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs @@ -125,7 +125,317 @@ public void ChangeSetBufferIsGiven_ReevaluatorNotificationsAreBufferedOnSchedule results.RecordedItemsByKey.Values.Should().BeEquivalentTo(source.Items, "no items should have changed, within the source"); results.HasCompleted.Should().BeFalse("the source has not completed"); } - + + [Fact] + public void ChangeSetBufferIsGiven_RemoveDuringWindow_DropsPendingRefresh() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + source.AddOrUpdate(item1); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged, + changeSetBuffer: TimeSpan.FromSeconds(10), + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.RecordedChangeSets.Count.Should().Be(1, "the initial Add changeset should propagate"); + + + // UUT Action (publish reevaluator notification at T=5) + scheduler.AdvanceTo(TimeSpan.FromSeconds(5).Ticks); + ++item1.Value; + + + // UUT Action (remove the item at T=9, before the buffer window ends at T=15) + scheduler.AdvanceTo(TimeSpan.FromSeconds(9).Ticks); + source.Remove(item1); + + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "the Remove should propagate immediately"); + results.RecordedChangeSets.Skip(1).First().Removes.Should().Be(1, "the source removed item #1"); + + + // UUT Action (advance past the buffer window) + scheduler.AdvanceTo(TimeSpan.FromSeconds(20).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Should().BeEmpty( + "a Refresh for a key the source has already removed is incoherent on arrival"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void ChangeSetBufferIsGiven_UpdateDuringWindow_RefreshEmittedForNewInstance() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1V1 = new Item() { Id = 1 }; + using var item1V2 = new Item() { Id = 1 }; + source.AddOrUpdate(item1V1); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged, + changeSetBuffer: TimeSpan.FromSeconds(10), + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.RecordedChangeSets.Count.Should().Be(1, "the initial Add changeset should propagate"); + + + // UUT Action (v1's reevaluator fires at T=5) + scheduler.AdvanceTo(TimeSpan.FromSeconds(5).Ticks); + ++item1V1.Value; + + + // UUT Action (Update replaces v1 with v2 at T=9, within the window armed by the v1 refresh) + scheduler.AdvanceTo(TimeSpan.FromSeconds(9).Ticks); + source.AddOrUpdate(item1V2); + + results.RecordedChangeSets.Skip(1).Count().Should().Be(1, "the Update should propagate immediately"); + + + // UUT Action (v2's reevaluator fires at T=12, arming a fresh window for T=22) + scheduler.AdvanceTo(TimeSpan.FromSeconds(12).Ticks); + ++item1V2.Value; + + + // UUT Action (advance to T=15, the original v1-armed window boundary) + scheduler.AdvanceTo(TimeSpan.FromSeconds(15).Ticks); + + results.RecordedChangeSets.Skip(2).Should().BeEmpty( + "the v1-armed window must not fire after the Update replaced its pending refresh"); + + + // UUT Action (advance past the new buffer window at T=22) + scheduler.AdvanceTo(TimeSpan.FromSeconds(22).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(2).Count().Should().Be(1, "the v2 buffer window has expired"); + results.RecordedChangeSets.Skip(2).First().Refreshes.Should().Be(1, "exactly one Refresh is buffered for the key"); + results.RecordedChangeSets.Skip(2).First().First().Current.Should().BeSameAs(item1V2, "a Refresh carries the instance the source currently holds, not a superseded one"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void ChangeSetBufferIsGiven_MultipleUpdatesDuringWindow_OnlyLatestRefreshEmitted() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1V1 = new Item() { Id = 1 }; + using var item1V2 = new Item() { Id = 1 }; + using var item1V3 = new Item() { Id = 1 }; + source.AddOrUpdate(item1V1); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged, + changeSetBuffer: TimeSpan.FromSeconds(10), + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + results.RecordedChangeSets.Count.Should().Be(1, "the initial Add changeset should propagate"); + + + // UUT Action (v1 reEval at T=2, then Update to v2 at T=4) + scheduler.AdvanceTo(TimeSpan.FromSeconds(2).Ticks); + ++item1V1.Value; + + scheduler.AdvanceTo(TimeSpan.FromSeconds(4).Ticks); + source.AddOrUpdate(item1V2); + + + // UUT Action (v2 reEval at T=6, then Update to v3 at T=8) + scheduler.AdvanceTo(TimeSpan.FromSeconds(6).Ticks); + ++item1V2.Value; + + scheduler.AdvanceTo(TimeSpan.FromSeconds(8).Ticks); + source.AddOrUpdate(item1V3); + + + // UUT Action (v3 reEval at T=10) + scheduler.AdvanceTo(TimeSpan.FromSeconds(10).Ticks); + ++item1V3.Value; + + results.RecordedChangeSets.Skip(1).Count().Should().Be(2, "two Updates propagated immediately"); + + + // UUT Action (advance past v3's window at T=20) + scheduler.AdvanceTo(TimeSpan.FromSeconds(20).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.Skip(3).Count().Should().Be(1, "the chain produces a single Refresh changeset"); + results.RecordedChangeSets.Skip(3).First().Refreshes.Should().Be(1, "the chain coalesces to a single Refresh"); + results.RecordedChangeSets.Skip(3).First().First().Current.Should().BeSameAs(item1V3, "a Refresh carries the instance the source currently holds, not a superseded one"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void ChangeSetBufferIsGiven_SourceCompletesBeforeWindowExpires_PendingRefreshIsEmittedBeforeCompletion() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item = new Item() { Id = 1 }; + source.AddOrUpdate(item); + + var scheduler = new TestScheduler(); + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValueChanged, + changeSetBuffer: TimeSpan.FromSeconds(10), + scheduler: scheduler) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + + // UUT Action (reevaluator fires at T=5, arms the buffer window for T=15) + scheduler.AdvanceTo(TimeSpan.FromSeconds(5).Ticks); + ++item.Value; + + + // UUT Action (complete the source and the reevaluator while the buffer window is still open) + source.Complete(); + item.Complete(); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.SelectMany(static cs => cs).Where(static c => c.Reason is ChangeReason.Refresh).Should().HaveCount(1, + "a pending buffered refresh must surface before completion, even when source and reevaluator have already completed"); + results.HasCompleted.Should().BeTrue( + "completion of all upstream subscriptions triggers an immediate flush of pending refreshes"); + + + // UUT Action (advance past the original window boundary; the timer was cancelled when sources completed) + scheduler.AdvanceTo(TimeSpan.FromSeconds(15).Ticks); + + results.RecordedChangeSets.SelectMany(static cs => cs).Where(static c => c.Reason is ChangeReason.Refresh).Should().HaveCount(1, + "the pending timer was disposed when sources completed; no second refresh should fire at the window boundary"); + } + + [Fact] + public void NoChangeSetBuffer_AddAndRemoveInSameChangeset_NoRefreshEmitted() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1 = new Item() { Id = 1 }; + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValue) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + + // UUT Action (Add + Remove in a single changeset) + source.Edit(updater => + { + updater.AddOrUpdate(item1); + updater.Remove(item1); + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.SelectMany(static cs => cs).Where(static c => c.Reason is ChangeReason.Refresh).Should().BeEmpty( + "a sync reevaluator emission queued during Add must not surface once the same drain removes the item"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void NoChangeSetBuffer_AddAndUpdateInSameChangeset_NoRefreshFromObsoleteInstance() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1V1 = new Item() { Id = 1 }; + using var item1V2 = new Item() { Id = 1 }; + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValue) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + + // UUT Action (Add v1 + Update to v2 in a single changeset) + source.Edit(updater => + { + updater.AddOrUpdate(item1V1); + updater.AddOrUpdate(item1V2); + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.SelectMany(static cs => cs).Where(static c => c.Reason is ChangeReason.Refresh).Should().BeEmpty( + "a Refresh carrying an instance the source has already replaced is incoherent on arrival"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + + [Fact] + public void NoChangeSetBuffer_MultipleUpdatesInSameChangeset_NoRefreshFromIntermediateInstances() + { + // Setup + using var source = new TestSourceCache(Item.SelectId); + + using var item1V1 = new Item() { Id = 1 }; + using var item1V2 = new Item() { Id = 1 }; + using var item1V3 = new Item() { Id = 1 }; + + + // UUT Initialization + using var subscription = BuildUut( + source: source.Connect(), + reevaluator: Item.ObserveValue) + .ValidateSynchronization() + .ValidateChangeSets(Item.SelectId) + .RecordCacheItems(out var results); + + + // UUT Action (Add v1 + Update v2 + Update v3 in a single changeset) + source.Edit(updater => + { + updater.AddOrUpdate(item1V1); + updater.AddOrUpdate(item1V2); + updater.AddOrUpdate(item1V3); + }); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.SelectMany(static cs => cs).Where(static c => c.Reason is ChangeReason.Refresh).Should().BeEmpty( + "every Refresh carrying a value the source has already superseded is incoherent on arrival"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + [Fact] public void ItemIsAdded_SubscribesToReevaluator() { @@ -495,7 +805,7 @@ public void ReevaluatorEmitsAsynchronously_ItemRefreshes() results.HasCompleted.Should().BeFalse("the source has not completed"); } - [Fact(Skip = "Existing defect, #1099")] + [Fact] public void ReevaluatorEmitsImmediately_ItemDoesNotRefresh() { // Setup @@ -523,7 +833,7 @@ public void ReevaluatorEmitsImmediately_ItemDoesNotRefresh() results.HasCompleted.Should().BeFalse("the source has not completed"); } - [Theory(Skip = "Existing defect. Docs say that ignoring reevaluator exceptions is intentional, but it shouldn't be. Basic RX philosophy is that exceptions should basically always propagate.")] + [Theory] [InlineData(NotificationStrategy.Immediate)] [InlineData(NotificationStrategy.Asynchronous)] public void ReevaluatorFails_ErrorPropagates(NotificationStrategy notificationStrategy) @@ -564,7 +874,7 @@ public void ReevaluatorFails_ErrorPropagates(NotificationStrategy notificationSt } } - [Fact(Skip = "Existing defect. Docs say that ignoring reevaluator exceptions is intentional, but it shouldn't be. Basic RX philosophy is that exceptions should basically always propagate.")] + [Fact] public void ReevaluatorThrows_ExceptionPropagates() { // Setup diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs index 3e96d6e96..7e0136a4a 100644 --- a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.WithoutKey.cs @@ -13,7 +13,7 @@ public static partial class AutoRefreshOnObservableFixture public class WithoutKey : Base { - [Fact(Skip = "Existing defect: reevaluator is not null checked, throws NRW on first notification, instead")] + [Fact] public void ReevaluatorIsNull_ThrowsException() => FluentActions.Invoking(() => ObservableCacheEx.AutoRefreshOnObservable( source: Observable.Never>(), diff --git a/src/DynamicData.Tests/Cache/FilterOnObservableFixture.cs b/src/DynamicData.Tests/Cache/FilterOnObservableFixture.cs index 84b487055..a6b3a4228 100644 --- a/src/DynamicData.Tests/Cache/FilterOnObservableFixture.cs +++ b/src/DynamicData.Tests/Cache/FilterOnObservableFixture.cs @@ -160,6 +160,27 @@ public void ObservableFilterDuplicateValuesHaveNoEffect() filterStats.Summary.Overall.Adds.Should().Be(MagicNumber); } + [Fact] + public void FilterObservableError_PropagatesDownstream() + { + // arrange: a filter observable that errors on the second emission + var filterSubject = new Subject(); + var error = new InvalidOperationException("filter exploded"); + Exception? observed = null; + + using var sub = _source.Connect() + .FilterOnObservable(_ => filterSubject) + .Subscribe(_ => { }, ex => observed = ex); + + AddPeople(MagicNumber); + + // act: trigger the error + filterSubject.OnError(error); + + // assert + observed.Should().BeSameAs(error, "errors from per-item filter observables must terminate the downstream stream"); + } + [Fact] public void ObservableFilterChangesCanBeBuffered() { diff --git a/src/DynamicData.Tests/Cache/MergeManyWithKeyOverloadFixture.cs b/src/DynamicData.Tests/Cache/MergeManyWithKeyOverloadFixture.cs index e6987e846..e307a9dd0 100644 --- a/src/DynamicData.Tests/Cache/MergeManyWithKeyOverloadFixture.cs +++ b/src/DynamicData.Tests/Cache/MergeManyWithKeyOverloadFixture.cs @@ -82,19 +82,17 @@ public void SingleItemCompleteWillNotMergedStream() } [Fact] - public void SingleItemFailWillNotFailMergedStream() + public void SingleItemFailWillFailMergedStream() { var failed = false; - var stream = _source.Connect().MergeMany((o, key) => o.Observable).Subscribe(_ => { }, ex => failed = true); + using var stream = _source.Connect().MergeMany((o, key) => o.Observable).Subscribe(_ => { }, ex => failed = true); var item = new ObjectWithObservable(1); _source.AddOrUpdate(item); item.FailObservable(new Exception("Test exception")); - stream.Dispose(); - - failed.Should().BeFalse(); + failed.Should().BeTrue("a faulted inner observable terminates the merged stream with that error"); } /// @@ -141,27 +139,25 @@ public void MergedStreamCompletesWhenSourceAndItemsComplete() } /// - /// Stream completes even if one of the children fails. + /// Inner-observable errors propagate to the merged stream. /// [Fact] - public void MergedStreamCompletesIfLastItemFails() + public void MergedStreamFailsIfChildFails() { var receivedError = default(Exception); + var expectedError = new Exception("Test exception"); var streamCompleted = false; - var sourceCompleted = false; var item = new ObjectWithObservable(1); _source.AddOrUpdate(item); - using var stream = _source.Connect().Do(_ => { }, () => sourceCompleted = true) + using var stream = _source.Connect() .MergeMany((o, i) => o.Observable).Subscribe(_ => { }, err => receivedError = err, () => streamCompleted = true); - _source.Dispose(); - item.FailObservable(new Exception("Test exception")); + item.FailObservable(expectedError); - receivedError.Should().Be(default); - sourceCompleted.Should().BeTrue(); - streamCompleted.Should().BeTrue(); + receivedError.Should().Be(expectedError, "a faulted inner observable terminates the merged stream with the same error"); + streamCompleted.Should().BeFalse("an errored stream does not also complete"); } /// diff --git a/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs b/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs index 7483eccb4..6599159cd 100644 --- a/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs +++ b/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs @@ -96,10 +96,12 @@ public async Task RemoveFlowsToTheEnd() var cache = new SourceCache(p => p.Name); var people = Enumerable.Range(1, count).Select(l => new Person("Name" + l, l)).ToArray(); + var randomizer = new Bogus.Randomizer(42); + cache.Connect() .TransformAsync(async person => { - await Task.Delay(Random.Shared.Next(1, 12)); + await Task.Delay(randomizer.Number(1, 12)); return person; }) .Bind(out collection) @@ -112,10 +114,9 @@ public async Task RemoveFlowsToTheEnd() } // Add one event as an initial empty change set is sent - // NOTE TO SELF: How did this test previously work ! - var changes = await collection.ToObservableChangeSet().Take(count * 2 + 1).ToList(); + var changes = await collection.ToObservableChangeSet().Take(count * 2 + 1).ToList(); - changes.Count.Should().Be(201); + changes.Count.Should().Be(201); collection.Count.Should().Be(0); } diff --git a/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs new file mode 100644 index 000000000..acab42046 --- /dev/null +++ b/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs @@ -0,0 +1,181 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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; +using System.Reactive; +using System.Reactive.Linq; +using System.Reactive.Subjects; + +using DynamicData.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Microsoft.Reactive.Testing; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +public sealed class AutoRefreshOrchestratorFixture +{ + private sealed record Item(int Id); + + [Fact] + public void OnDrainComplete_Unbuffered_FlushesPendingRefreshes() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = new AutoRefresh.Orchestrator( + context, emitter, + reEvaluator: (item, key) => new Subject(), + buffer: null, + scheduler: null); + + var item = new Item(1); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); + emitter.Values.Should().HaveCount(1); + + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + emitter.Values.Should().HaveCount(2, "unbuffered drain should flush the pending refresh"); + emitter.Values[1].Should().ContainSingle(c => c.Reason == ChangeReason.Refresh && c.Key == 1); + } + + [Fact] + public void OnInner_KeyInSourceTouched_SuppressesRefresh() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = new AutoRefresh.Orchestrator( + context, emitter, + reEvaluator: (item, key) => new Subject(), + buffer: null, + scheduler: null); + + var item = new Item(1); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); + orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + emitter.Values.Should().HaveCount(1, "the synchronous inner refresh on a source-touched key must be suppressed"); + emitter.Values[0].Should().ContainSingle(c => c.Reason == ChangeReason.Add); + } + + [Fact] + public void OnDrainComplete_Buffered_NoFlushUntilTimer() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var scheduler = new TestScheduler(); + var orchestrator = new AutoRefresh.Orchestrator( + context, emitter, + reEvaluator: (item, key) => new Subject(), + buffer: TimeSpan.FromSeconds(10), + scheduler: scheduler); + + var item = new Item(1); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + var preInnerCount = emitter.Values.Count; + orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + emitter.Values.Count.Should().Be(preInnerCount, + "buffered drain should defer the refresh to the timer; no flush yet"); + } + + [Fact] + public void OnDrainComplete_BufferedWithIsFinal_FlushesSynchronously() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var scheduler = new TestScheduler(); + var orchestrator = new AutoRefresh.Orchestrator( + context, emitter, + reEvaluator: (item, key) => new Subject(), + buffer: TimeSpan.FromSeconds(10), + scheduler: scheduler); + + var item = new Item(1); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + var preInnerCount = emitter.Values.Count; + orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); + orchestrator.OnDrainComplete(isFinal: true, wasReentrant: false); + + emitter.Values.Count.Should().Be(preInnerCount + 1, + "isFinal must force a synchronous flush of pending refreshes even when buffered"); + emitter.Values[preInnerCount].Should().ContainSingle(c => c.Reason == ChangeReason.Refresh && c.Key == 1); + } + + [Fact] + public void OnItemRemoved_DropsPendingRefreshForKey() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = new AutoRefresh.Orchestrator( + context, emitter, + reEvaluator: (item, key) => new Subject(), + buffer: null, + scheduler: null); + + var item = new Item(1); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + var preInnerCount = emitter.Values.Count; + orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 1, item) }); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + context.UntrackCalls.Should().Contain(1); + var refreshCount = 0; + foreach (var cs in emitter.Values) + { + foreach (var change in cs) + { + if (change.Reason == ChangeReason.Refresh) + refreshCount++; + } + } + + refreshCount.Should().Be(0, "a Refresh whose value is obsoleted by a Remove must never emit"); + } + + [Fact] + public void OnItemRefreshed_DropsPendingRefreshForKey() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var scheduler = new TestScheduler(); + var orchestrator = new AutoRefresh.Orchestrator( + context, emitter, + reEvaluator: (item, key) => new Subject(), + buffer: TimeSpan.FromSeconds(10), + scheduler: scheduler); + + var item = new Item(1); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + var preSourceRefreshCount = emitter.Values.Count; + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Refresh, 1, item) }); + orchestrator.OnDrainComplete(isFinal: true, wasReentrant: false); + + emitter.Values.Count.Should().Be(preSourceRefreshCount + 1, + "the source's Refresh subsumes the pending one; flushing it would be redundant"); + emitter.Values[preSourceRefreshCount].Should().ContainSingle(c => c.Reason == ChangeReason.Refresh && c.Key == 1); + } +} diff --git a/src/DynamicData.Tests/Internal/CacheParentSubscriptionFixture.cs b/src/DynamicData.Tests/Internal/CacheParentSubscriptionFixture.cs deleted file mode 100644 index 5f11b9fc5..000000000 --- a/src/DynamicData.Tests/Internal/CacheParentSubscriptionFixture.cs +++ /dev/null @@ -1,375 +0,0 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. -// 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; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Subjects; -using System.Threading; -using System.Threading.Tasks; - -using Bogus; - -using DynamicData.Internal; -using DynamicData.Tests.Utilities; - -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.Internal; - -/// -/// Tests for -/// behavioral contracts using a minimal concrete subclass. -/// -public sealed class CacheParentSubscriptionFixture -{ - private const int SeedMin = 1; - private const int SeedMax = 10000; - private const int BatchSizeMin = 2; - private const int BatchSizeMax = 8; - - private readonly Randomizer _rand = new(55); - - /// Test item with a typed key — no string parsing. - private sealed record TestItem(int Key, string Value); - - [Fact] - public void ParentOnNext_CalledForEachChangeSet() - { - var itemCount = _rand.Number(BatchSizeMin, BatchSizeMax); - using var source = new SourceCache(x => x.Key); - var observer = new TestObserver(); - using var sub = new TestSubscription(observer); - sub.ExposeCreateParent(source.Connect()); - - var items = Enumerable.Range(0, itemCount) - .Select(i => new TestItem(_rand.Number(SeedMin, SeedMax) + i * 100, _rand.String2(_rand.Number(3, 10)))) - .ToList(); - - foreach (var item in items) - source.AddOrUpdate(item); - - sub.ParentCallCount.Should().Be(items.Count, "ParentOnNext should fire once per changeset"); - observer.EmitCount.Should().Be(items.Count, "EmitChanges should fire after each parent update"); - } - - [Fact] - public void ChildOnNext_CalledForEachEmission() - { - using var source = new SourceCache(x => x.Key); - var childSubjects = new List>(); - var observer = new TestObserver(); - using var sub = new TestSubscription(observer, key => - { - var subj = new Subject(); - childSubjects.Add(subj); - return subj; - }); - sub.ExposeCreateParent(source.Connect()); - - var key = _rand.Number(SeedMin, SeedMax); - source.AddOrUpdate(new TestItem(key, "parent")); - - childSubjects.Should().HaveCount(1); - var childValue = _rand.String2(_rand.Number(5, 15)); - childSubjects[0].OnNext(childValue); - - sub.ChildCalls.Should().ContainSingle() - .Which.Should().Be((childValue, key)); - } - - [Fact] - public void EmitChanges_FiresOnceForBatch() - { - var batchSize = _rand.Number(BatchSizeMin, BatchSizeMax); - using var source = new SourceCache(x => x.Key); - var observer = new TestObserver(); - using var sub = new TestSubscription(observer); - sub.ExposeCreateParent(source.Connect()); - - source.Edit(updater => - { - for (var i = 0; i < batchSize; i++) - updater.AddOrUpdate(new TestItem(i + 1, _rand.String2(_rand.Number(3, 8)))); - }); - - sub.ParentCallCount.Should().Be(1, "single batch = single ParentOnNext"); - sub.EmitCallCount.Should().Be(1, "single batch = single EmitChanges"); - } - - [Fact] - public void Batching_ChildUpdatesSettleBeforeEmit() - { - var batchSize = _rand.Number(BatchSizeMin, BatchSizeMax); - using var source = new SourceCache(x => x.Key); - var observer = new TestObserver(); - var childCount = 0; - using var sub = new TestSubscription(observer, key => - { - Interlocked.Increment(ref childCount); - return new BehaviorSubject($"sync-{key}"); - }); - sub.ExposeCreateParent(source.Connect()); - - source.Edit(updater => - { - for (var i = 0; i < batchSize; i++) - updater.AddOrUpdate(new TestItem(i + 1, _rand.String2(_rand.Number(3, 8)))); - }); - - childCount.Should().Be(batchSize, "each item should create a child"); - sub.EmitCallCount.Should().BeGreaterThanOrEqualTo(1, - "EmitChanges fires after parent + children settle"); - } - - [Fact] - public void Completion_RequiresParentAndAllChildren() - { - using var source = new TestSourceCache(x => x.Key); - var childSubjects = new List>(); - var observer = new TestObserver(); - using var sub = new TestSubscription(observer, key => - { - var subj = new Subject(); - childSubjects.Add(subj); - return subj; - }); - sub.ExposeCreateParent(source.Connect()); - - source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); - childSubjects.Should().HaveCount(1); - - source.Complete(); - observer.IsCompleted.Should().BeFalse("parent complete but child still active"); - - childSubjects[0].OnCompleted(); - observer.IsCompleted.Should().BeTrue("OnCompleted fires when parent + all children complete"); - } - - [Fact] - public void Completion_ParentOnly_NoChildren() - { - using var source = new TestSourceCache(x => x.Key); - var observer = new TestObserver(); - using var sub = new TestSubscription(observer); - sub.ExposeCreateParent(source.Connect()); - - source.Complete(); - observer.IsCompleted.Should().BeTrue("immediate OnCompleted when no children"); - } - - [Fact] - public void Disposal_StopsAllEmissions() - { - using var source = new SourceCache(x => x.Key); - var childSubjects = new List>(); - var observer = new TestObserver(); - var sub = new TestSubscription(observer, key => - { - var subj = new Subject(); - childSubjects.Add(subj); - return subj; - }); - sub.ExposeCreateParent(source.Connect()); - - source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); - var emitsBefore = observer.EmitCount; - - sub.Dispose(); - - source.AddOrUpdate(new TestItem(_rand.Number(SeedMin + SeedMax, SeedMax * 2), "after")); - if (childSubjects.Count > 0) - childSubjects[0].OnNext("after-dispose"); - - observer.EmitCount.Should().Be(emitsBefore, "no emissions after disposal"); - } - - [Fact] - public void Error_Propagates() - { - using var source = new TestSourceCache(x => x.Key); - var observer = new TestObserver(); - using var sub = new TestSubscription(observer); - sub.ExposeCreateParent(source.Connect()); - - var error = new InvalidOperationException("test error"); - source.SetError(error); - - observer.Error.Should().BeSameAs(error); - } - - [Fact] - public void Serialization_ParentAndChildDoNotInterleave() - { - using var source = new SourceCache(x => x.Key); - var callLog = new List(); - var observer = new TestObserver(); - using var sub = new TestSubscription( - observer, - key => - { - var subj = new Subject(); - return subj; - }, - onParent: () => { lock (callLog) callLog.Add("P-start"); Thread.Sleep(1); lock (callLog) callLog.Add("P-end"); }, - onChild: () => { lock (callLog) callLog.Add("C-start"); Thread.Sleep(1); lock (callLog) callLog.Add("C-end"); }); - sub.ExposeCreateParent(source.Connect()); - - source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); - - // Start/end pairs should not interleave - for (var i = 0; i + 1 < callLog.Count; i += 2) - { - var prefix = callLog[i].Split('-')[0]; - callLog[i + 1].Should().StartWith(prefix, "operations should not interleave"); - } - } - - /// - /// Proves CPS delivery runs without holding the lock. Two TestSubscription instances - /// whose EmitChanges callbacks write into each other's source cache — creating a - /// cross-cache cycle. Deadlocks on unfixed code, passes after the fix. - /// - [Trait("Category", "ExplicitDeadlock")] - [Fact] - public async Task DeadlockProof_CrossFeedingSubscriptions() - { - var iterations = _rand.Number(50, 150); - - using var sourceA = new SourceCache(x => x.Key); - using var sourceB = new SourceCache(x => x.Key); - - // Each TestSubscription's EmitChanges writes into the OTHER source (limited to prevent infinite loops) - var observerA = new CrossFeedObserver(sourceB, 100_001, iterations); - using var subA = new TestSubscription(observerA); - subA.ExposeCreateParent(sourceA.Connect()); - - var observerB = new CrossFeedObserver(sourceA, 200_001, iterations); - using var subB = new TestSubscription(observerB); - subB.ExposeCreateParent(sourceB.Connect()); - - using var barrier = new Barrier(2); - - var taskA = Task.Run(() => - { - var tRand = new Randomizer(56); - barrier.SignalAndWait(); - for (var i = 0; i < iterations; i++) - sourceA.AddOrUpdate(new TestItem(tRand.Number(1, 50_000), tRand.String2(5))); - }); - - var taskB = Task.Run(() => - { - var tRand = new Randomizer(57); - barrier.SignalAndWait(); - for (var i = 0; i < iterations; i++) - sourceB.AddOrUpdate(new TestItem(tRand.Number(50_001, 100_000), tRand.String2(5))); - }); - - var completed = Task.WhenAll(taskA, taskB); - var finished = await Task.WhenAny(completed, Task.Delay(TimeSpan.FromSeconds(30))); - finished.Should().BeSameAs(completed, - "cross-feeding CacheParentSubscriptions should not deadlock"); - } - - // ═══════════════════════════════════════════════════════════════ - // Test Infrastructure - // ═══════════════════════════════════════════════════════════════ - - /// Observer that writes into another cache on every emission — creates cross-cache cycle. - private sealed class CrossFeedObserver(SourceCache target, int idBase, int maxCrossWrites) : IObserver> - { - private int _counter; - - public void OnNext(IChangeSet value) - { - // Limit cross-writes to prevent infinite feedback loops - if (Interlocked.Increment(ref _counter) <= maxCrossWrites) - { - target.AddOrUpdate(new TestItem(idBase + _counter, "cross")); - } - } - - public void OnError(Exception error) { } - - public void OnCompleted() { } - } - - /// - /// Minimal concrete CacheParentSubscription for testing. - /// - private sealed class TestSubscription : CacheParentSubscription> - { - private readonly Func>? _childFactory; - private readonly Action? _onParent; - private readonly Action? _onChild; - private readonly ChangeAwareCache _cache = new(); - - public int ParentCallCount; - public int EmitCallCount; - public readonly List<(string Value, int Key)> ChildCalls = []; - - public TestSubscription( - IObserver> observer, - Func>? childFactory = null, - Action? onParent = null, - Action? onChild = null) - : base(observer) - { - _childFactory = childFactory; - _onParent = onParent; - _onChild = onChild; - } - - public void ExposeCreateParent(IObservable> source) - => CreateParentSubscription(source); - - protected override void ParentOnNext(IChangeSet changes) - { - Interlocked.Increment(ref ParentCallCount); - _onParent?.Invoke(); - _cache.Clone(changes); - - if (_childFactory is not null) - { - foreach (var change in (ChangeSet)changes) - { - if (change.Reason is ChangeReason.Add or ChangeReason.Update) - AddChildSubscription(MakeChildObservable(_childFactory(change.Key)), change.Key); - else if (change.Reason is ChangeReason.Remove) - RemoveChildSubscription(change.Key); - } - } - } - - protected override void ChildOnNext(string child, int parentKey) - { - _onChild?.Invoke(); - ChildCalls.Add((child, parentKey)); - _cache.AddOrUpdate(new TestItem(parentKey, child), parentKey); - } - - protected override void EmitChanges(IObserver> observer) - { - Interlocked.Increment(ref EmitCallCount); - var changes = _cache.CaptureChanges(); - if (changes.Count > 0) - observer.OnNext(changes); - } - } - - /// Observer that records emissions, completion, and errors. - private sealed class TestObserver : IObserver> - { - public int EmitCount; - public bool IsCompleted; - public Exception? Error; - - public void OnNext(IChangeSet value) => Interlocked.Increment(ref EmitCount); - public void OnError(Exception error) => Error = error; - public void OnCompleted() => IsCompleted = true; - } -} \ No newline at end of file diff --git a/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs new file mode 100644 index 000000000..7c20f5234 --- /dev/null +++ b/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs @@ -0,0 +1,97 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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; +using System.Reactive.Linq; + +using DynamicData.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +public sealed class ChangeSetOrchestratorFixture +{ + private sealed record Source(int Id); + + [Fact] + public void OnSourceChangeSet_InvokesOnSourceChangeForEachChange() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver>(); + var reasons = new System.Collections.Generic.List(); + + var orchestrator = new IntObservableCacheEx.ChangeSetOrchestrator( + context, emitter, + innerFactory: (item, key) => Observable.Empty(), + onSourceChange: (cache, change) => reasons.Add(change.Reason), + onInner: (cache, key, item, value) => cache.AddOrUpdate(value, key)); + + orchestrator.OnSourceChangeSet(new ChangeSet + { + new(ChangeReason.Add, 1, new Source(1)), + new(ChangeReason.Refresh, 2, new Source(2)), + }); + + reasons.Should().Equal(new[] { ChangeReason.Add, ChangeReason.Refresh }); + } + + [Fact] + public void OnInner_RoutesValueAndDrainCaptures() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver>(); + var source = new Source(1); + + var orchestrator = new IntObservableCacheEx.ChangeSetOrchestrator( + context, emitter, + innerFactory: (item, key) => Observable.Empty(), + onSourceChange: (cache, change) => { }, + onInner: (cache, key, item, value) => cache.AddOrUpdate(value, key)); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, source) }); + orchestrator.OnInner((source, "v"), 1); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + emitter.Values.Should().HaveCount(1); + emitter.Values[0].Should().ContainSingle(c => c.Key == 1 && c.Current == "v"); + } + + [Fact] + public void OnItemRemoved_UntracksKey() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver>(); + var orchestrator = new IntObservableCacheEx.ChangeSetOrchestrator( + context, emitter, + innerFactory: (item, key) => Observable.Empty(), + onSourceChange: (cache, change) => { }, + onInner: (cache, key, item, value) => { }); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Source(1)) }); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 1, new Source(1)) }); + + context.UntrackCalls.Should().Equal(new[] { 1 }); + } + + [Fact] + public void OnDrainComplete_EmptyChangeSet_NoEmission() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver>(); + var orchestrator = new IntObservableCacheEx.ChangeSetOrchestrator( + context, emitter, + innerFactory: (item, key) => Observable.Empty(), + onSourceChange: (cache, change) => { }, + onInner: (cache, key, item, value) => { }); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Source(1)) }); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + emitter.Values.Should().BeEmpty("drain end with an empty ChangeAwareCache must not emit"); + } +} diff --git a/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs new file mode 100644 index 000000000..a43617dd4 --- /dev/null +++ b/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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; +using System.Collections.Generic; + +using DynamicData.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +public sealed class LambdaCacheOrchestratorFixture +{ + private sealed record Item(int Id); + + [Fact] + public void OnSourceChangeSet_ForwardsChangesToLambda() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver(); + var receivedChanges = new List>(); + + var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( + context, emitter, + onSourceChangeSet: (changes, _) => receivedChanges.Add(changes), + onInner: (value, key, _) => { }, + onDrainComplete: obs => { }); + + var changeset = new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }; + orchestrator.OnSourceChangeSet(changeset); + + receivedChanges.Should().ContainSingle().Which.Should().BeSameAs(changeset); + } + + [Fact] + public void OnSourceChangeSet_ForwardsContextToLambda() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver(); + ICacheOrchestratorContext? receivedContext = null; + + var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( + context, emitter, + onSourceChangeSet: (_, ctx) => receivedContext = ctx, + onInner: (value, key, _) => { }, + onDrainComplete: obs => { }); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }); + + receivedContext.Should().BeSameAs(context, "the lambda overload forwards the captured context as-is"); + } + + [Fact] + public void OnInner_ForwardsValueAndKeyToLambda() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver(); + var received = new List<(string Value, int Key)>(); + + var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( + context, emitter, + onSourceChangeSet: (_, _) => { }, + onInner: (v, k, _) => received.Add((v, k)), + onDrainComplete: _ => { }); + + orchestrator.OnInner("hello", 42); + + received.Should().Equal(new[] { ("hello", 42) }); + } + + [Fact] + public void OnInner_ForwardsEmitterToLambda() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver(); + IObserver? receivedEmitter = null; + + var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( + context, emitter, + onSourceChangeSet: (_, _) => { }, + onInner: (_, _, em) => receivedEmitter = em, + onDrainComplete: _ => { }); + + orchestrator.OnInner("hello", 42); + + receivedEmitter.Should().BeSameAs(emitter, "the lambda overload forwards the emitter as-is to onInner"); + } + + [Fact] + public void OnDrainComplete_ForwardsEmitterToLambda() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver(); + IObserver? receivedObserver = null; + + var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( + context, emitter, + onSourceChangeSet: (_, _) => { }, + onInner: (_, _, _) => { }, + onDrainComplete: obs => receivedObserver = obs); + + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + receivedObserver.Should().BeSameAs(emitter, "the lambda overload forwards the emitter as-is to onDrainComplete"); + } +} diff --git a/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs new file mode 100644 index 000000000..609871f22 --- /dev/null +++ b/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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.Linq; +using System.Reactive.Linq; + +using DynamicData.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +public sealed class MergedListOrchestratorFixture +{ + private sealed record Item(int Id); + + private static IntObservableCacheEx.MergedListOrchestrator Build( + FakeOrchestratorContext> context, + CollectingObserver> emitter) => + new(context, emitter, + changeSetSelector: (item, key) => Observable.Empty>(), + equalityComparer: null); + + [Fact] + public void OnItemAdded_TracksAndForwardsListChanges() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = Build(context, emitter); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }); + orchestrator.OnInner(new ChangeSet { new(ListChangeReason.Add, "value-a") }, 1); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + context.TrackCalls.Should().HaveCount(1); + emitter.Values.Should().HaveCount(1); + } + + [Fact] + public void OnItemRemoved_UntracksAndRemovesPriorItemsFromTracker() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = Build(context, emitter); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 1, new Item(1)) }); + + context.UntrackCalls.Should().Contain(1, "Remove must propagate as Untrack on the orchestrator's context"); + } + + [Fact] + public void OnItemUpdated_TracksNewInnerObservable() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = Build(context, emitter); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }); + var preTrackCount = context.TrackCalls.Count; + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Update, 1, new Item(1), new Item(1)) }); + + context.TrackCalls.Count.Should().Be(preTrackCount + 1, "Update must re-Track with the new item's inner observable"); + } + + [Fact] + public void MultipleKeys_TrackedIndependently() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = Build(context, emitter); + + orchestrator.OnSourceChangeSet(new ChangeSet + { + new(ChangeReason.Add, 1, new Item(1)), + new(ChangeReason.Add, 2, new Item(2)), + }); + + context.TrackCalls.Select(t => t.Key).Should().BeEquivalentTo(new[] { 1, 2 }); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 1, new Item(1)) }); + + context.UntrackCalls.Should().Equal(new[] { 1 }); + context.Tracked.Keys.Should().Equal(new[] { 2 }, "Remove on key 1 must not affect key 2"); + } +} diff --git a/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs new file mode 100644 index 000000000..cdfc96a3f --- /dev/null +++ b/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs @@ -0,0 +1,115 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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.Linq; +using System.Reactive.Linq; + +using DynamicData.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +public sealed class MergedOrchestratorFixture +{ + private sealed record Item(int Id, string Tag); + + private static IntObservableCacheEx.MergedOrchestrator Build( + FakeOrchestratorContext> context, + CollectingObserver> emitter, + bool reevalOnRefresh = false) => + new(context, emitter, + changeSetSelector: (item, key) => Observable.Empty>(), + equalityComparer: null, + comparer: null, + reevalOnRefresh: reevalOnRefresh); + + [Fact] + public void OnItemAdded_TracksAndRoutesInnerChangeSet() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = Build(context, emitter); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1, "a")) }); + orchestrator.OnInner(new ChangeSet { new(ChangeReason.Add, "key-1", "v") }, 1); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + context.TrackCalls.Should().HaveCount(1); + emitter.Values.Should().HaveCount(1); + emitter.Values[0].Should().ContainSingle(c => c.Key == "key-1"); + } + + [Fact] + public void OnItemRemoved_UntracksAndDropsItemsFromTracker() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = Build(context, emitter); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1, "a")) }); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 1, new Item(1, "a")) }); + + context.UntrackCalls.Should().Contain(1, "Remove must propagate as Untrack on the orchestrator's context"); + } + + [Fact] + public void OnItemUpdated_TracksNewInnerObservable() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = Build(context, emitter); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1, "first")) }); + var preTrackCount = context.TrackCalls.Count; + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Update, 1, new Item(1, "second"), new Item(1, "first")) }); + + context.TrackCalls.Count.Should().Be(preTrackCount + 1, "Update must re-Track with the new item's inner observable"); + } + + [Fact] + public void OnItemRefreshed_WithoutReevalOnRefresh_NoEmission() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = Build(context, emitter, reevalOnRefresh: false); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1, "a")) }); + orchestrator.OnInner(new ChangeSet { new(ChangeReason.Add, "key-a", "v") }, 1); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + var preCount = emitter.Values.Count; + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Refresh, 1, new Item(1, "a")) }); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + emitter.Values.Count.Should().Be(preCount, "Refresh with reevalOnRefresh=false must not emit"); + } + + [Fact] + public void MultipleKeys_TrackedIndependently() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = Build(context, emitter); + + orchestrator.OnSourceChangeSet(new ChangeSet + { + new(ChangeReason.Add, 1, new Item(1, "a")), + new(ChangeReason.Add, 2, new Item(2, "b")), + }); + + context.TrackCalls.Select(t => t.Key).Should().BeEquivalentTo(new[] { 1, 2 }); + context.Tracked.Keys.Should().BeEquivalentTo(new[] { 1, 2 }); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 1, new Item(1, "a")) }); + + context.UntrackCalls.Should().Equal(new[] { 1 }); + context.Tracked.Keys.Should().Equal(new[] { 2 }, "Remove on key 1 must not affect key 2"); + } +} diff --git a/src/DynamicData.Tests/Internal/OrchestrateFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateFixture.cs new file mode 100644 index 000000000..284fba294 --- /dev/null +++ b/src/DynamicData.Tests/Internal/OrchestrateFixture.cs @@ -0,0 +1,561 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading; +using System.Threading.Tasks; + +using Bogus; + +using DynamicData.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +/// +/// Behavioral contract tests for the Orchestrate primitive: source/inner serialization, +/// per-drain coalesced emission, completion counting, error propagation, and cross-cache safety. +/// +public sealed class OrchestrateFixture +{ + private const int SeedMin = 1; + private const int SeedMax = 10000; + private const int BatchSizeMin = 2; + private const int BatchSizeMax = 8; + + private readonly Randomizer _rand = new(55); + + private sealed record TestItem(int Key, string Value); + + private static (IObservable> Observable, Func Orchestrator) Wire( + IObservable> source, + Func>? childFactory = null, + Action? onParent = null, + Action? onChild = null) + { + TestOrchestrator? captured = null; + var observable = source.Orchestrate, TestOrchestrator>( + (ctx, em) => captured = new TestOrchestrator(ctx, em, childFactory, onParent, onChild)); + return (observable, () => captured ?? throw new InvalidOperationException("Subscribe to the returned observable first.")); + } + + [Fact] + public void ParentOnNext_CalledForEachChangeSet() + { + var itemCount = _rand.Number(BatchSizeMin, BatchSizeMax); + using var source = new SourceCache(x => x.Key); + var observer = new TestObserver(); + var (observable, getOrchestrator) = Wire(source.Connect()); + using var sub = observable.Subscribe(observer); + + var items = Enumerable.Range(0, itemCount) + .Select(i => new TestItem(_rand.Number(SeedMin, SeedMax) + i * 100, _rand.String2(_rand.Number(3, 10)))) + .ToList(); + + foreach (var item in items) + source.AddOrUpdate(item); + + getOrchestrator().ParentCallCount.Should().Be(items.Count, "OnSourceChangeSet should fire once per changeset"); + observer.EmitCount.Should().Be(items.Count, "Emit should fire after each parent update"); + } + + [Fact] + public void ChildOnNext_CalledForEachEmission() + { + using var source = new SourceCache(x => x.Key); + var childSubjects = new List>(); + var observer = new TestObserver(); + var (observable, getOrchestrator) = Wire(source.Connect(), key => + { + var subj = new Subject(); + childSubjects.Add(subj); + return subj; + }); + using var sub = observable.Subscribe(observer); + + var key = _rand.Number(SeedMin, SeedMax); + source.AddOrUpdate(new TestItem(key, "parent")); + + childSubjects.Should().HaveCount(1); + var childValue = _rand.String2(_rand.Number(5, 15)); + childSubjects[0].OnNext(childValue); + + getOrchestrator().ChildCalls.Should().ContainSingle() + .Which.Should().Be((childValue, key)); + } + + [Fact] + public void EmitChanges_FiresOnceForBatch() + { + var batchSize = _rand.Number(BatchSizeMin, BatchSizeMax); + using var source = new SourceCache(x => x.Key); + var observer = new TestObserver(); + var (observable, getOrchestrator) = Wire(source.Connect()); + using var sub = observable.Subscribe(observer); + + source.Edit(updater => + { + for (var i = 0; i < batchSize; i++) + updater.AddOrUpdate(new TestItem(i + 1, _rand.String2(_rand.Number(3, 8)))); + }); + + var orchestrator = getOrchestrator(); + orchestrator.ParentCallCount.Should().Be(1, "single batch = single OnSourceChangeSet"); + orchestrator.EmitCallCount.Should().Be(1, "single batch = single Emit"); + } + + [Fact] + public void Batching_ChildUpdatesSettleBeforeEmit() + { + var batchSize = _rand.Number(BatchSizeMin, BatchSizeMax); + using var source = new SourceCache(x => x.Key); + var observer = new TestObserver(); + var childCount = 0; + var (observable, getOrchestrator) = Wire(source.Connect(), key => + { + Interlocked.Increment(ref childCount); + return new BehaviorSubject($"sync-{key}"); + }); + using var sub = observable.Subscribe(observer); + + source.Edit(updater => + { + for (var i = 0; i < batchSize; i++) + updater.AddOrUpdate(new TestItem(i + 1, _rand.String2(_rand.Number(3, 8)))); + }); + + childCount.Should().Be(batchSize, "each item should create a child"); + getOrchestrator().EmitCallCount.Should().BeGreaterThanOrEqualTo(1, + "Emit fires after parent + children settle"); + } + + [Fact] + public void Completion_RequiresParentAndAllChildren() + { + using var source = new TestSourceCache(x => x.Key); + var childSubjects = new List>(); + var observer = new TestObserver(); + var (observable, _) = Wire(source.Connect(), key => + { + var subj = new Subject(); + childSubjects.Add(subj); + return subj; + }); + using var sub = observable.Subscribe(observer); + + source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); + childSubjects.Should().HaveCount(1); + + source.Complete(); + observer.IsCompleted.Should().BeFalse("parent complete but child still active"); + + childSubjects[0].OnCompleted(); + observer.IsCompleted.Should().BeTrue("OnCompleted fires when parent + all children complete"); + } + + [Fact] + public void Completion_ParentOnly_NoChildren() + { + using var source = new TestSourceCache(x => x.Key); + var observer = new TestObserver(); + var (observable, _) = Wire(source.Connect()); + using var sub = observable.Subscribe(observer); + + source.Complete(); + observer.IsCompleted.Should().BeTrue("immediate OnCompleted when no children"); + } + + [Fact] + public void OnDrainComplete_IsFinalIsFalseUntilSourceAndAllInnersComplete() + { + using var source = new TestSourceCache(x => x.Key); + var childSubject = new Subject(); + var observer = new TestObserver(); + var (observable, getOrchestrator) = Wire(source.Connect(), _ => childSubject); + using var sub = observable.Subscribe(observer); + + source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); + childSubject.OnNext("v1"); + + var orchestrator = getOrchestrator(); + orchestrator.IsFinalLog.Should().NotBeEmpty("OnDrainComplete should fire while source is active"); + orchestrator.IsFinalLog.Should().AllBeEquivalentTo(false, + "isFinal must be false on every call while source and inners are still active"); + + var preSourceCompleteCount = orchestrator.IsFinalLog.Count; + source.Complete(); + orchestrator.IsFinalLog.Skip(preSourceCompleteCount).Should().AllBeEquivalentTo(false, + "isFinal must remain false while at least one inner subscription is still active"); + observer.IsCompleted.Should().BeFalse("downstream must not complete while inners are active"); + + var preInnerCompleteCount = orchestrator.IsFinalLog.Count; + childSubject.OnCompleted(); + orchestrator.IsFinalLog.Skip(preInnerCompleteCount).Should().Contain(true, + "isFinal must be true on the OnDrainComplete fired after source and all tracked inners have completed"); + observer.IsCompleted.Should().BeTrue("downstream completion must follow isFinal=true"); + } + + [Fact] + public void Disposal_StopsAllEmissions() + { + using var source = new SourceCache(x => x.Key); + var childSubjects = new List>(); + var observer = new TestObserver(); + var (observable, _) = Wire(source.Connect(), key => + { + var subj = new Subject(); + childSubjects.Add(subj); + return subj; + }); + var sub = observable.Subscribe(observer); + + source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); + var emitsBefore = observer.EmitCount; + + sub.Dispose(); + + source.AddOrUpdate(new TestItem(_rand.Number(SeedMin + SeedMax, SeedMax * 2), "after")); + if (childSubjects.Count > 0) + childSubjects[0].OnNext("after-dispose"); + + observer.EmitCount.Should().Be(emitsBefore, "no emissions after disposal"); + } + + [Fact] + public void Error_Propagates() + { + using var source = new TestSourceCache(x => x.Key); + var observer = new TestObserver(); + var (observable, _) = Wire(source.Connect()); + using var sub = observable.Subscribe(observer); + + var error = new InvalidOperationException("test error"); + source.SetError(error); + + observer.Error.Should().BeSameAs(error); + } + + [Fact] + public void InnerError_PropagatesAndTerminatesStream() + { + using var source = new SourceCache(x => x.Key); + var childSubjects = new List>(); + var observer = new TestObserver(); + var (observable, _) = Wire(source.Connect(), key => + { + var subj = new Subject(); + childSubjects.Add(subj); + return subj; + }); + using var sub = observable.Subscribe(observer); + + source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); + childSubjects.Should().HaveCount(1); + + var error = new InvalidOperationException("inner-error"); + childSubjects[0].OnError(error); + + observer.Error.Should().BeSameAs(error, + "an inner observable error terminates the merged stream with the same error"); + observer.IsCompleted.Should().BeFalse( + "an errored stream must not also complete"); + } + + [Fact] + public void SourceAlreadyCompleted_PropagatesCompletion() + { + var observer = new TestObserver(); + var (observable, _) = Wire(Observable.Empty>()); + using var sub = observable.Subscribe(observer); + + observer.IsCompleted.Should().BeTrue( + "a pre-completed source must propagate completion through the orchestrator on subscribe"); + } + + [Fact] + public void SourceAlreadyErrored_PropagatesError() + { + var observer = new TestObserver(); + var error = new InvalidOperationException("sync-error"); + var (observable, _) = Wire(Observable.Throw>(error)); + using var sub = observable.Subscribe(observer); + + observer.Error.Should().BeSameAs(error, + "a synchronously-erroring source must propagate the error through the orchestrator"); + } + + [Fact] + public async Task Serialization_ParentAndChildDoNotInterleave() + { + const int iterations = 50; + + using var source = new SourceCache(x => x.Key); + var callLog = new List(); + Subject? childSubject = null; + var observer = new TestObserver(); + var (observable, _) = Wire( + source.Connect(), + childFactory: _ => + { + childSubject = new Subject(); + return childSubject; + }, + onParent: () => { lock (callLog) callLog.Add("P-start"); Thread.Sleep(1); lock (callLog) callLog.Add("P-end"); }, + onChild: () => { lock (callLog) callLog.Add("C-start"); Thread.Sleep(1); lock (callLog) callLog.Add("C-end"); }); + using var sub = observable.Subscribe(observer); + + source.AddOrUpdate(new TestItem(1, "init")); + childSubject.Should().NotBeNull(); + + using var barrier = new Barrier(2); + var parentTask = Task.Run(() => + { + barrier.SignalAndWait(); + for (var i = 0; i < iterations; i++) + source.Refresh(new TestItem(1, "v")); + }); + var childTask = Task.Run(() => + { + barrier.SignalAndWait(); + for (var i = 0; i < iterations; i++) + childSubject!.OnNext($"c{i}"); + }); + await Task.WhenAll(parentTask, childTask); + + lock (callLog) + { + callLog.Count.Should().BeGreaterThan(0); + (callLog.Count % 2).Should().Be(0, "every start must have a matching end"); + for (var i = 0; i + 1 < callLog.Count; i += 2) + { + var prefix = callLog[i].Split('-')[0]; + callLog[i].Should().EndWith("start"); + callLog[i + 1].Should().Be($"{prefix}-end", "parent and child callbacks must not interleave"); + } + } + } + + [Trait("Category", "ExplicitDeadlock")] + [Fact] + public async Task DeadlockProof_CrossFeedingSubscriptions() + { + var iterations = _rand.Number(50, 150); + + using var sourceA = new SourceCache(x => x.Key); + using var sourceB = new SourceCache(x => x.Key); + + var observerA = new CrossFeedObserver(sourceB, 100_001, iterations); + var (observableA, _) = Wire(sourceA.Connect()); + using var subA = observableA.Subscribe(observerA); + + var observerB = new CrossFeedObserver(sourceA, 200_001, iterations); + var (observableB, _) = Wire(sourceB.Connect()); + using var subB = observableB.Subscribe(observerB); + + using var barrier = new Barrier(2); + + var taskA = Task.Run(() => + { + var tRand = new Randomizer(56); + barrier.SignalAndWait(); + for (var i = 0; i < iterations; i++) + sourceA.AddOrUpdate(new TestItem(tRand.Number(1, 50_000), tRand.String2(5))); + }); + + var taskB = Task.Run(() => + { + var tRand = new Randomizer(57); + barrier.SignalAndWait(); + for (var i = 0; i < iterations; i++) + sourceB.AddOrUpdate(new TestItem(tRand.Number(50_001, 100_000), tRand.String2(5))); + }); + + var completed = Task.WhenAll(taskA, taskB); + var finished = await Task.WhenAny(completed, Task.Delay(TimeSpan.FromSeconds(30))); + finished.Should().BeSameAs(completed, + "cross-feeding Orchestrate subscriptions should not deadlock"); + } + + [Fact] + public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstream() + { + var producerCount = 8; + var emissionsPerProducer = 200; + var totalEmissions = producerCount * emissionsPerProducer; + + using var source = new SourceCache(x => x.Key); + + var innerSubjects = new Dictionary>(); + for (var i = 1; i <= producerCount; i++) + { + innerSubjects[i] = new Subject(); + } + + var (observable, getOrchestrator) = Wire(source.Connect(), key => innerSubjects[key]); + var observer = new TestObserver(); + using var sub = observable.Subscribe(observer); + + for (var i = 1; i <= producerCount; i++) + { + source.AddOrUpdate(new TestItem(i, "init")); + } + + var orchestrator = getOrchestrator(); + + lock (orchestrator.ChildCalls) + { + orchestrator.ChildCalls.Clear(); + } + + using var barrier = new Barrier(producerCount); + var producers = Enumerable.Range(1, producerCount).Select(producerId => Task.Run(() => + { + barrier.SignalAndWait(); + for (var i = 0; i < emissionsPerProducer; i++) + { + innerSubjects[producerId].OnNext($"{producerId}-{i}"); + } + })).ToArray(); + + await Task.WhenAll(producers); + + SpinWait.SpinUntil( + () => { lock (orchestrator.ChildCalls) return orchestrator.ChildCalls.Count >= totalEmissions; }, + TimeSpan.FromSeconds(5)) + .Should().BeTrue($"all {totalEmissions} emissions must reach OnInner within 5 seconds"); + + lock (orchestrator.ChildCalls) + { + orchestrator.ChildCalls.Count.Should().Be(totalEmissions, + "every concurrent inner emission must reach OnInner; reentrant drain during emit must not drop items"); + } + + foreach (var subj in innerSubjects.Values) + { + subj.Dispose(); + } + } + + [Fact] + public void LambdaOverload_MultipleSubscriptions_DoNotShareOrchestrator() + { + using var source = new SourceCache(x => x.Key); + + var emitCalls = 0; + var contexts = new List(); + + var observable = source.Connect().Orchestrate( + onSourceChangeSet: (changes, context) => + { + lock (contexts) + { + contexts.Add(System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(context)); + } + }, + onInner: (_, _, _) => { }, + onDrainComplete: _ => Interlocked.Increment(ref emitCalls)); + + using var subA = observable.Subscribe(); + using var subB = observable.Subscribe(); + + source.AddOrUpdate(new TestItem(1, "item")); + + lock (contexts) + { + contexts.Distinct().Count().Should().Be(2, + "each subscription must receive its own orchestrator instance with its own context"); + } + } + + private sealed class CrossFeedObserver(SourceCache target, int idBase, int maxCrossWrites) : IObserver> + { + private int _counter; + + public void OnNext(IChangeSet value) + { + if (Interlocked.Increment(ref _counter) <= maxCrossWrites) + { + target.AddOrUpdate(new TestItem(idBase + _counter, "cross")); + } + } + + public void OnError(Exception error) { } + + public void OnCompleted() { } + } + + private sealed class TestOrchestrator( + ICacheOrchestratorContext context, + IObserver> emitter, + Func>? childFactory = null, + Action? onParent = null, + Action? onChild = null) + : ICacheOrchestrator> + { + private readonly ChangeAwareCache _cache = new(); + + public int ParentCallCount; + public int EmitCallCount; + public readonly List<(string Value, int Key)> ChildCalls = []; + public readonly List IsFinalLog = []; + public readonly List WasReentrantLog = []; + + public void OnSourceChangeSet(IChangeSet changes) + { + Interlocked.Increment(ref ParentCallCount); + onParent?.Invoke(); + _cache.Clone(changes); + + if (childFactory is not null) + { + foreach (var change in (ChangeSet)changes) + { + if (change.Reason is ChangeReason.Add or ChangeReason.Update) + context.Track(change.Key, childFactory(change.Key)); + else if (change.Reason is ChangeReason.Remove) + context.Untrack(change.Key); + } + } + } + + public void OnInner(string child, int parentKey) + { + onChild?.Invoke(); + ChildCalls.Add((child, parentKey)); + _cache.AddOrUpdate(new TestItem(parentKey, child), parentKey); + } + + public void OnDrainComplete(bool isFinal, bool wasReentrant) + { + IsFinalLog.Add(isFinal); + WasReentrantLog.Add(wasReentrant); + + var changes = _cache.CaptureChanges(); + if (changes.Count > 0) + { + Interlocked.Increment(ref EmitCallCount); + emitter.OnNext(changes); + } + } + } + + private sealed class TestObserver : IObserver> + { + public int EmitCount; + public bool IsCompleted; + public Exception? Error; + + public void OnNext(IChangeSet value) => Interlocked.Increment(ref EmitCount); + public void OnError(Exception error) => Error = error; + public void OnCompleted() => IsCompleted = true; + } +} diff --git a/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs b/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs index e67444cb6..808fed286 100644 --- a/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs +++ b/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs @@ -175,6 +175,36 @@ public async Task ConcurrentMultiSourceDelivery() } } + [Fact] + public void OnDrainComplete_RefiresAfterReentrantDrainTriggeredByCallback() + { + var callCount = 0; + var delivered = new List(); + DeliverySubQueue? sub = null; + + SharedDeliveryQueue queue = null!; + queue = new SharedDeliveryQueue(onDrainComplete: _ => + { + if (Interlocked.Increment(ref callCount) == 1) + { + using var scope = sub!.AcquireLock(); + scope.EnqueueNext(42); + } + }); + + var observer = new TestObserver(delivered.Add); + sub = queue.CreateQueue(observer); + + using (var scope = sub.AcquireLock()) + { + scope.EnqueueNext(1); + } + + delivered.Should().Equal(new[] { 1, 42 }, "the reentrantly-enqueued item must be delivered"); + callCount.Should().BeGreaterThanOrEqualTo(2, + "onDrainComplete must fire again after the reentrant drain consumed the enqueued item"); + } + private sealed class TestObserver(Action onNext) : IObserver { public Exception? Error { get; private set; } diff --git a/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs new file mode 100644 index 000000000..e09efe782 --- /dev/null +++ b/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs @@ -0,0 +1,105 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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; +using System.Reactive.Linq; +using System.Threading.Tasks; + +using DynamicData.Cache.Internal; +using DynamicData.Kernel; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +public sealed class TransformManyAsyncOrchestratorFixture +{ + private sealed record Item(int Id); + + [Fact] + public void OnItemAdded_TracksTransformedObservable() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = new TransformManyAsync.Orchestrator( + context, emitter, + transformer: (item, key) => Task.FromResult>>( + Observable.Empty>()), + equalityComparer: null, + comparer: null, + errorHandler: null); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }); + + context.TrackCalls.Should().HaveCount(1); + context.TrackCalls[0].Key.Should().Be(1); + } + + [Fact] + public void OnItemRemoved_UntracksAndDropsFromTracker() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = new TransformManyAsync.Orchestrator( + context, emitter, + transformer: (item, key) => Task.FromResult>>( + Observable.Empty>()), + equalityComparer: null, + comparer: null, + errorHandler: null); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 1, new Item(1)) }); + + context.UntrackCalls.Should().Equal(new[] { 1 }); + } + + [Fact] + public void OnDrainComplete_EmitsAccumulatedTrackerState() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = new TransformManyAsync.Orchestrator( + context, emitter, + transformer: (item, key) => Task.FromResult>>( + Observable.Empty>()), + equalityComparer: null, + comparer: null, + errorHandler: null); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }); + orchestrator.OnInner(new ChangeSet { new(ChangeReason.Add, "x", "value-x") }, 1); + + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + emitter.Values.Should().HaveCount(1); + emitter.Values[0].Should().ContainSingle(c => c.Key == "x"); + } + + [Fact] + public async Task TransformerThrows_RoutesThroughErrorHandler() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + Error? capturedError = null; + + var orchestrator = new TransformManyAsync.Orchestrator( + context, emitter, + transformer: (item, key) => throw new InvalidOperationException("transformer-broke"), + equalityComparer: null, + comparer: null, + errorHandler: err => capturedError = err); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }); + + var trackedObs = context.Tracked[1]; + await trackedObs.DefaultIfEmpty().LastOrDefaultAsync(); + + capturedError.Should().NotBeNull(); + capturedError!.Exception!.Message.Should().Be("transformer-broke"); + } +} diff --git a/src/DynamicData.Tests/List/MergeManyFixture.cs b/src/DynamicData.Tests/List/MergeManyFixture.cs index 997e91f9f..63a75f1cf 100644 --- a/src/DynamicData.Tests/List/MergeManyFixture.cs +++ b/src/DynamicData.Tests/List/MergeManyFixture.cs @@ -111,27 +111,25 @@ public void MergedStreamCompletesWhenSourceAndItemsComplete() } /// - /// Stream completes even if one of the children fails. + /// A faulted inner observable terminates the merged stream with that error. /// [Fact] - public void MergedStreamCompletesIfLastItemFails() + public void MergedStreamFailsIfChildFails() { var receivedError = default(Exception); var streamCompleted = false; - var sourceCompleted = false; + var expectedError = new Exception("Test exception"); var item = new ObjectWithObservable(1); _source.Add(item); - using var stream = _source.Connect().Do(_ => { }, () => sourceCompleted = true) + using var stream = _source.Connect() .MergeMany(o => o.Observable).Subscribe(_ => { }, err => receivedError = err, () => streamCompleted = true); - _source.Dispose(); - item.FailObservable(new Exception("Test exception")); + item.FailObservable(expectedError); - receivedError.Should().Be(default); - sourceCompleted.Should().BeTrue(); - streamCompleted.Should().BeTrue(); + receivedError.Should().Be(expectedError, "a faulted inner observable terminates the merged stream with the same error"); + streamCompleted.Should().BeFalse("an errored stream does not also complete"); } /// diff --git a/src/DynamicData.Tests/Utilities/CollectingObserver.cs b/src/DynamicData.Tests/Utilities/CollectingObserver.cs new file mode 100644 index 000000000..cd4167cc3 --- /dev/null +++ b/src/DynamicData.Tests/Utilities/CollectingObserver.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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; +using System.Collections.Generic; + +namespace DynamicData.Tests.Utilities; + +/// +/// that records every OnNext value and the terminal state. +/// +internal sealed class CollectingObserver : IObserver +{ + private readonly List _values = []; + + public IReadOnlyList Values => _values; + + public Exception? Error { get; private set; } + + public bool IsCompleted { get; private set; } + + public void OnNext(T value) => _values.Add(value); + + public void OnError(Exception error) => Error = error; + + public void OnCompleted() => IsCompleted = true; +} diff --git a/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs b/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs new file mode 100644 index 000000000..7e67ca5a8 --- /dev/null +++ b/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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; +using System.Collections.Generic; + +using DynamicData.Cache.Internal; + +namespace DynamicData.Tests.Utilities; + +/// +/// Test fake for that records +/// and calls and exposes the currently registered +/// observables via . +/// +/// Source changeset key type. +/// Value type emitted by per-key inner observables. +internal sealed class FakeOrchestratorContext : ICacheOrchestratorContext + where TKey : notnull + where TInner : notnull +{ + private readonly Dictionary> _tracked = []; + + public List<(TKey Key, IObservable Observable)> TrackCalls { get; } = []; + + public List UntrackCalls { get; } = []; + + public IReadOnlyDictionary> Tracked => _tracked; + + public void Track(TKey key, IObservable observable) + { + TrackCalls.Add((key, observable)); + _tracked[key] = observable; + } + + public void Untrack(TKey key) + { + UntrackCalls.Add(key); + _tracked.Remove(key); + } + + public IObservable Serialize(IObservable observable) => observable; +} diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index ee81fe581..00a297146 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. @@ -6,35 +6,130 @@ using System.Reactive.Disposables; using System.Reactive.Linq; +using DynamicData.Internal; + namespace DynamicData.Cache.Internal; -internal sealed class AutoRefresh(IObservable> source, Func> reEvaluator, TimeSpan? buffer = null, IScheduler? scheduler = null) +internal sealed class AutoRefresh( + IObservable> source, + Func> reEvaluator, + TimeSpan? buffer = null, + IScheduler? scheduler = null) where TObject : notnull where TKey : notnull { - private readonly Func> _reEvaluator = reEvaluator ?? throw new ArgumentNullException(nameof(reEvaluator)); + public IObservable> Run() + { + var sched = buffer is null ? null : scheduler ?? GlobalConfig.DefaultScheduler; + return source.Orchestrate, IChangeSet, Orchestrator>( + (context, emitter) => new Orchestrator(context, emitter, reEvaluator, buffer, sched)); + } + + /// + /// Forwards each source changeset immediately. Refresh notifications from per-key reevaluators + /// are coalesced (latest wins) and flushed at drain end (unbuffered) or via a Timer armed by + /// the first pending refresh (buffered). Source events drop any pending refresh for the same + /// key; reevaluator emissions for source-touched keys in the same drain are suppressed. + /// + internal sealed class Orchestrator( + ICacheOrchestratorContext> context, + IObserver> emitter, + Func> reEvaluator, + TimeSpan? buffer, + IScheduler? scheduler) + : CacheOrchestratorBase, IChangeSet>(context, emitter), IDisposable + { + private readonly Dictionary> _pendingRefreshes = new(); + private readonly HashSet _sourceTouched = []; + private readonly SerialDisposable _timerSubscription = new(); + + public void Dispose() => _timerSubscription.Dispose(); + + public override void OnSourceChangeSet(IChangeSet changes) + { + base.OnSourceChangeSet(changes); + if (changes.Count > 0) + { + Emitter.OnNext(changes); + } + } + + public override void OnInner(Change refresh, TKey key) + { + if (_sourceTouched.Contains(key)) + { + return; + } + + _pendingRefreshes[key] = refresh; - private readonly IScheduler _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; + if (buffer is { } window) + { + _timerSubscription.Disposable ??= Context.Serialize(Observable.Timer(window, scheduler!)) + .SubscribeSafe(_ => FlushPending(), Emitter.OnError); + } + } - private readonly IObservable> _source = source ?? throw new ArgumentNullException(nameof(source)); + public override void OnDrainComplete(bool isFinal, bool wasReentrant) + { + _sourceTouched.Clear(); - public IObservable> Run() => Observable.Create>( - observer => + // Flush when unbuffered, or on the final drain (the timer would otherwise be cancelled + // by stream termination). + if (isFinal || buffer is null) { - var shared = _source.Publish(); + FlushPending(); + } + } + + protected override void OnItemAdded(TObject item, TKey key) + { + _sourceTouched.Add(key); + Context.Track(key, reEvaluator(item, key).Select(_ => new Change(ChangeReason.Refresh, key, item))); + } + + protected override void OnItemUpdated(TObject current, TObject previous, TKey key) + { + DropPending(key); + OnItemAdded(current, key); + } + + protected override void OnItemRemoved(TObject item, TKey key) + { + _sourceTouched.Add(key); + DropPending(key); + Context.Untrack(key); + } - // monitor each item observable and create change - var changes = shared.MergeMany((t, k) => _reEvaluator(t, k).Select(_ => new Change(ChangeReason.Refresh, k, t))); + protected override void OnItemRefreshed(TObject item, TKey key) + { + _sourceTouched.Add(key); - // create a change set, either buffered or one item at the time - IObservable> refreshChanges = buffer is null ? - changes.Select(c => new ChangeSet(new[] { c })) : - changes.Buffer(buffer.Value, _scheduler).Where(list => list.Count > 0).Select(items => new ChangeSet(items)); + // Source is forwarding its own Refresh; drop any pending from a prior drain so the + // consumer doesn't see two Refresh notifications back-to-back. + DropPending(key); + } - // publish refreshes and underlying changes - var queue = new SharedDeliveryQueue(); - var publisher = shared.SynchronizeSafe(queue).Merge(refreshChanges.SynchronizeSafe(queue)).SubscribeSafe(observer); + private void DropPending(TKey key) + { + if (_pendingRefreshes.Remove(key) && _pendingRefreshes.Count == 0) + { + _timerSubscription.Disposable = null; + } + } + + private void FlushPending() + { + _timerSubscription.Disposable = null; + + if (_pendingRefreshes.Count == 0) + { + return; + } - return new CompositeDisposable(publisher, shared.Connect(), queue); - }); + var batch = new ChangeSet(_pendingRefreshes.Values); + _pendingRefreshes.Clear(); + Emitter.OnNext(batch); + } + } } diff --git a/src/DynamicData/Cache/Internal/CacheOrchestration.cs b/src/DynamicData/Cache/Internal/CacheOrchestration.cs new file mode 100644 index 000000000..557ba7a55 --- /dev/null +++ b/src/DynamicData/Cache/Internal/CacheOrchestration.cs @@ -0,0 +1,177 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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.Diagnostics; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using DynamicData.Internal; + +namespace DynamicData.Cache.Internal; + +/// +/// Drives an against a source +/// changeset. builds a fresh per-subscription +/// from the supplied . +/// +/// Type of items in the source changeset. +/// Type of the source changeset key. +/// Type of values emitted by the per-key inner observables. +/// Type delivered downstream. +/// Concrete orchestrator type returned by the factory. Generic-typed so dispatch sites devirtualize. +/// The keyed source changeset stream. +/// Builds the per-subscription orchestrator from its runtime context and emitter. +internal sealed class CacheOrchestration( + IObservable> source, + Func, IObserver, TOrch> factory) + where TSource : notnull + where TKey : notnull + where TInner : notnull + where TOrch : ICacheOrchestrator +{ + public IObservable Run() => Observable.Create(observer => new OrchestratorContext(source, observer, factory)); + + private sealed class OrchestratorContext : ICacheOrchestratorContext, IDisposable + { + private readonly KeyedDisposable _innerSubscriptions = new(); + private readonly SingleAssignmentDisposable _sourceSubscription = new(); + private readonly SharedDeliveryQueue _queue; + private readonly DeliverySubQueue _emitter; + private readonly TOrch _orchestrator = default!; + private int _subscriptionCounter = 1; // Includes the source subscription, so starts at 1 and not 0. + private int _completionEmitted; + private bool _disposed; + + public OrchestratorContext( + IObservable> source, + IObserver observer, + Func, IObserver, TOrch> factory) + { + _queue = new SharedDeliveryQueue(onDrainComplete: OnDrainComplete); + + try + { + // Emitter sub-queue is allocated first so it drains last (LIFO); source-triggered + // sync inner emissions land on later-indexed queues and deliver before it. + _emitter = _queue.CreateQueue(observer); + + _orchestrator = factory(this, _emitter); + Debug.Assert(_orchestrator is not null, "Factory must not return null"); + + _sourceSubscription.Disposable = source + .SynchronizeSafe(_queue) + .SubscribeSafe( + onNext: OnSourceChangeSet, + onError: _emitter.OnError, + onCompleted: DecrementSubscriptionCount); + } + catch + { + _sourceSubscription.Dispose(); + _innerSubscriptions.Dispose(); + _queue.Dispose(); + _emitter?.Dispose(); + (_orchestrator as IDisposable)?.Dispose(); + throw; + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + // Stop incoming work before disposing the queue so source/inner pumps can't fire into + // a terminating queue. + _sourceSubscription.Dispose(); + _innerSubscriptions.Dispose(); + _queue.Dispose(); + _emitter.Dispose(); + (_orchestrator as IDisposable)?.Dispose(); + } + + public void Track(TKey key, IObservable observable) + { + Debug.Assert(observable is not null, "Use Untrack(key) to remove a tracked subscription"); + + // Increment before installing the new subscription so disposing the prior one (which + // decrements via Finally) cannot race the counter to zero between Track calls. + Interlocked.Increment(ref _subscriptionCounter); + + var container = _innerSubscriptions.Add(key, new SingleAssignmentDisposable()); + + // Finally fires on completion, error, AND disposal, so the counter always decrements. + // onCompleted only fires on normal completion, so a subscription disposed by Track + // replacing it (or by Dispose) does not trigger Remove from inside. + container.Disposable = observable! + .SynchronizeSafe(_queue) + .Finally(DecrementSubscriptionCount) + .SubscribeSafe( + onNext: value => OnInner(value, key), + onError: _emitter.OnError, + onCompleted: () => _innerSubscriptions.Remove(key)); + } + + public void Untrack(TKey key) => _innerSubscriptions.Remove(key); + + public IObservable Serialize(IObservable observable) => observable.SynchronizeSafe(_queue); + + private void OnSourceChangeSet(IChangeSet changes) + { + try + { + _orchestrator.OnSourceChangeSet(changes); + } + catch (Exception error) + { + _emitter.OnError(error); + } + } + + private void OnInner(TInner value, TKey key) + { + try + { + _orchestrator.OnInner(value, key); + } + catch (Exception error) + { + _emitter.OnError(error); + } + } + + private void OnDrainComplete(bool wasReentrant) + { + // Counter == 0 means source and every tracked inner have terminated. A latched flag + // would race when the orchestrator calls Track during the isFinal call. + var isFinal = Volatile.Read(ref _subscriptionCounter) == 0; + + try + { + _orchestrator.OnDrainComplete(isFinal, wasReentrant); + } + catch (Exception error) + { + _emitter.OnError(error); + return; + } + + // Re-check: if OnDrainComplete added a tracked subscription (re-establishing liveness), + // don't complete. CAS ensures exactly one OnCompleted across repeated drains seeing 0. + if (Volatile.Read(ref _subscriptionCounter) == 0 && Interlocked.CompareExchange(ref _completionEmitted, 1, 0) == 0) + { + _emitter.OnCompleted(); + } + } + + private void DecrementSubscriptionCount() + { + var remaining = Interlocked.Decrement(ref _subscriptionCounter); + Debug.Assert(remaining >= 0, "Subscription counter should never go negative"); + } + } +} diff --git a/src/DynamicData/Cache/Internal/CacheOrchestratorBase.cs b/src/DynamicData/Cache/Internal/CacheOrchestratorBase.cs new file mode 100644 index 000000000..4454181bf --- /dev/null +++ b/src/DynamicData/Cache/Internal/CacheOrchestratorBase.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace DynamicData.Cache.Internal; + +/// +/// Optional base for implementations +/// that prefer per- virtual hooks over decoding the changeset themselves. +/// +/// Type of items in the source changeset. +/// Type of the source changeset key. +/// Type of values emitted by the per-key inner observables. +/// Type delivered downstream via the emitter. +internal abstract class CacheOrchestratorBase( + ICacheOrchestratorContext context, + IObserver emitter) + : ICacheOrchestrator + where TSource : notnull + where TKey : notnull + where TInner : notnull +{ + protected ICacheOrchestratorContext Context => context; + + protected IObserver Emitter => emitter; + + public virtual void OnSourceChangeSet(IChangeSet changes) + { + foreach (var change in changes.ToConcreteType()) + { + switch (change.Reason) + { + case ChangeReason.Add: + OnItemAdded(change.Current, change.Key); + break; + + case ChangeReason.Update: + OnItemUpdated(change.Current, change.Previous.Value, change.Key); + break; + + case ChangeReason.Remove: + OnItemRemoved(change.Current, change.Key); + break; + + case ChangeReason.Refresh: + OnItemRefreshed(change.Current, change.Key); + break; + } + } + } + + public abstract void OnInner(TInner value, TKey key); + + public virtual void OnDrainComplete(bool isFinal, bool wasReentrant) + { + } + + protected virtual void OnItemAdded(TSource item, TKey key) + { + } + + protected virtual void OnItemUpdated(TSource current, TSource previous, TKey key) => OnItemAdded(current, key); + + protected virtual void OnItemRemoved(TSource item, TKey key) + { + } + + protected virtual void OnItemRefreshed(TSource item, TKey key) + { + } +} diff --git a/src/DynamicData/Cache/Internal/ChangeSetCache.cs b/src/DynamicData/Cache/Internal/ChangeSetCache.cs index d63a93bea..a5a2a6b2d 100644 --- a/src/DynamicData/Cache/Internal/ChangeSetCache.cs +++ b/src/DynamicData/Cache/Internal/ChangeSetCache.cs @@ -2,23 +2,21 @@ // 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.Linq; - namespace DynamicData.Cache.Internal; /// -/// Wraps an Observable ChangeSet while maintaining a copy of the aggregated changes. +/// Holds an observable changeset alongside an aggregated mirror cache. +/// applies a changeset to . /// /// ChangeSet Object Type. /// ChangeSet Key Type. -internal sealed class ChangeSetCache +internal sealed class ChangeSetCache(IObservable> source) where TObject : notnull where TKey : notnull { - public ChangeSetCache(IObservable> source) => - Source = source.Do(Cache.Clone); - public Cache Cache { get; } = new(); - public IObservable> Source { get; } + public IObservable> Source { get; } = source; + + public void Process(IChangeSet changes) => Cache.Clone(changes); } diff --git a/src/DynamicData/Cache/Internal/FilterOnObservable.cs b/src/DynamicData/Cache/Internal/FilterOnObservable.cs index d444d9ac4..2a6c2c019 100644 --- a/src/DynamicData/Cache/Internal/FilterOnObservable.cs +++ b/src/DynamicData/Cache/Internal/FilterOnObservable.cs @@ -1,9 +1,10 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. // 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.Concurrency; using System.Reactive.Linq; +using DynamicData.Internal; namespace DynamicData.Cache.Internal; @@ -11,22 +12,43 @@ internal sealed class FilterOnObservable(IObservable> _filterFactory = filterFactory ?? throw new ArgumentNullException(nameof(filterFactory)); - private readonly IObservable> _source = source ?? throw new ArgumentNullException(nameof(source)); - - public IObservable> Run() => - _source - .Transform((val, key) => new FilterProxy(val, _filterFactory(val, key))) - .AutoRefreshOnObservable(proxy => proxy.FilterObservable, buffer, scheduler) - .Filter(proxy => proxy.PassesFilter) - .TransformImmutable(proxy => proxy.Value); - - private sealed class FilterProxy(TObject obj, IObservable observable) + public IObservable> Run() => Observable.Defer(() => { - public TObject Value { get; } = obj; + var changes = source.OrchestrateChangeSets( + innerFactory: (item, key) => filterFactory(item, key).DistinctUntilChanged(), + onSourceChange: static (cache, change) => + { + // Drop the entry upfront on Add/Update/Remove. Synchronous emissions from the new inner + // observable collapse with this Remove inside the same drain cycle, so a passing item + // immediately re-adds. Refresh is propagated as-is. + if (change.Reason is ChangeReason.Add or ChangeReason.Update or ChangeReason.Remove) + { + cache.Remove(change.Key); + } + else if (change.Reason is ChangeReason.Refresh) + { + cache.Refresh(change.Key); + } + }, + onInner: static (cache, key, item, passes) => + { + if (passes) + { + cache.AddOrUpdate(item, key); + } + else + { + cache.Remove(key); + } + }); - public bool PassesFilter { get; private set; } + if (buffer is { } window) + { + var sched = scheduler ?? GlobalConfig.DefaultScheduler; + changes = changes.Publish(published => published.Buffer(() => published.Take(1).Delay(window, sched))) + .FlattenBufferResult(); + } - public IObservable FilterObservable => observable.DistinctUntilChanged().Do(filterValue => PassesFilter = filterValue); - } + return changes; + }); } diff --git a/src/DynamicData/Cache/Internal/GroupOnObservable.cs b/src/DynamicData/Cache/Internal/GroupOnObservable.cs index 0555004e1..68614dcbf 100644 --- a/src/DynamicData/Cache/Internal/GroupOnObservable.cs +++ b/src/DynamicData/Cache/Internal/GroupOnObservable.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. @@ -13,57 +13,29 @@ internal sealed class GroupOnObservable(IObservable> Run() => - Observable.Create>(observer => new Subscription(source, selectGroup, observer)); - - // Maintains state for a single subscription - private sealed class Subscription : CacheParentSubscription> - { - private readonly DynamicGrouper _grouper = new(); - private readonly Func> _selectGroup; - - public Subscription(IObservable> source, Func> selectGroup, IObserver> observer) - : base(observer) - { - _selectGroup = selectGroup; - CreateParentSubscription(source); - } - - protected override void ParentOnNext(IChangeSet changes) - { - // Process all the changes at once to preserve the changeset order - foreach (var change in changes.ToConcreteType()) - { - _grouper.ProcessChange(change); - - switch (change.Reason) + Observable.Using( + resourceFactory: () => new DynamicGrouper(), + observableFactory: grouper => source.Orchestrate>( + onSourceChangeSet: (changes, context) => { - // Shutdown existing sub (if any) and create a new one that - // Will update the group key for the current item - case ChangeReason.Add or ChangeReason.Update: - AddGroupSubscription(change.Current, change.Key); - break; - - // Shutdown the existing subscription - case ChangeReason.Remove: - RemoveChildSubscription(change.Key); - break; - } - } - } - - protected override void ChildOnNext((TGroupKey, TObject) tuple, TKey parentKey) => - _grouper.AddOrUpdate(parentKey, tuple.Item1, tuple.Item2); - - protected override void EmitChanges(IObserver> observer) => - _grouper.EmitChanges(observer); - - protected override void Dispose(bool disposing) - { - _grouper.Dispose(); - base.Dispose(disposing); - } - - private void AddGroupSubscription(TObject obj, TKey key) => - AddChildSubscription(MakeChildObservable(_selectGroup(obj, key).DistinctUntilChanged().Select(groupKey => (groupKey, obj))), key); - } + foreach (var change in changes.ToConcreteType()) + { + grouper.ProcessChange(change); + + switch (change.Reason) + { + case ChangeReason.Add or ChangeReason.Update: + var item = change.Current; + var key = change.Key; + context.Track(key, selectGroup(item, key).DistinctUntilChanged().Select(groupKey => (groupKey, item))); + break; + + case ChangeReason.Remove: + context.Untrack(change.Key); + break; + } + } + }, + onInner: (value, parentKey, _) => grouper.AddOrUpdate(parentKey, value.GroupKey, value.Item), + onDrainComplete: observer => grouper.EmitChanges(observer))); } diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs new file mode 100644 index 000000000..658c172aa --- /dev/null +++ b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace DynamicData.Cache.Internal; + +/// +/// Contract for orchestrators driven by Orchestrate. A fresh instance is constructed +/// per subscription (via the factory passed to Orchestrate), so per-subscription state +/// can live as fields with no isolation concerns. +/// +/// Type of items in the source changeset. +/// Type of the source changeset key. +/// Type of values emitted by the per-key inner observables. +/// Type delivered downstream via the emitter. +internal interface ICacheOrchestrator + where TSource : notnull + where TKey : notnull + where TInner : notnull +{ + /// + /// Invoked for each source changeset. + /// + /// The source changeset. + void OnSourceChangeSet(IChangeSet changes); + + /// + /// Invoked for each value emitted by a tracked inner observable, paired with its source key. + /// + /// The value emitted by the inner observable. + /// The source key the inner observable was registered against. + void OnInner(TInner value, TKey key); + + /// + /// Invoked at the end of each drain cycle of the shared delivery queue. Implementations that + /// coalesce source and inner activity into per-drain emissions flush their accumulated state to + /// the emitter here. May be invoked multiple times per source event when the orchestrator's own + /// emit triggers a reentrant drain; subsequent calls are no-ops when there is nothing to flush. + /// + /// + /// when this is the last drain before the downstream observer receives + /// OnCompleted (the source changeset and every tracked inner observable have all + /// completed). Implementations holding deferred state (timer-armed buffers, debounced batches) + /// should flush synchronously when this is . + /// + /// + /// when a reentrant drain occurred during the prior delivery cycle + /// (an orchestrator emit triggered same-thread re-entry into the drain loop). Most + /// orchestrators ignore this. + /// + void OnDrainComplete(bool isFinal, bool wasReentrant); +} diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs new file mode 100644 index 000000000..ce8ab4e6d --- /dev/null +++ b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace DynamicData.Cache.Internal; + +/// +/// Runtime context exposed to an +/// via its factory. Provides per-key inner observable lifecycle management and a hook to serialize +/// arbitrary observables through the same shared queue as source and inner notifications. +/// +/// Source changeset key type. +/// Value type emitted by per-key inner observables. +internal interface ICacheOrchestratorContext + where TKey : notnull + where TInner : notnull +{ + /// + /// Registers or replaces the inner observable associated with . If a prior + /// subscription exists for this key, it is disposed and replaced. The supplied observable is + /// automatically routed through the shared delivery queue and participates in completion + /// accounting (the downstream stream stays alive until every tracked subscription terminates). + /// + /// The source changeset key whose inner subscription should be (re)registered. + /// The new inner observable. + void Track(TKey key, IObservable observable); + + /// + /// Removes and disposes the inner subscription associated with , if any. + /// No-op if no subscription is currently registered for the key. + /// + /// The source changeset key whose inner subscription should be removed. + void Untrack(TKey key); + + /// + /// Wraps with the shared queue's synchronization gate so that + /// downstream operators (side-effecting Do calls, time-based buffering, etc.) run under + /// the same serialization as source and inner notifications. + /// + /// + /// Unlike , a Serialize-wrapped subscription does NOT participate in + /// completion accounting: downstream can complete while it is still active. + /// + /// Value type of the observable being serialized. + /// The observable to wrap. + /// A new observable whose OnNext delivery runs under the orchestrator's queue lock. + IObservable Serialize(IObservable observable); +} diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs new file mode 100644 index 000000000..c7c4da2b8 --- /dev/null +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs @@ -0,0 +1,109 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Roland Pheasant licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace DynamicData.Cache.Internal; + +/// +/// Internal companion to . Hosts extension methods that are part of the +/// internal library surface (operator primitives, composition helpers, lambda adapters) but are not +/// suitable for public exposure. Split across multiple files by concern. +/// +/// +/// +/// Choosing an Orchestrate* overload (decision table): +/// +/// +/// Operator shapeUse +/// +/// Single value type (TResult), needs explicit orchestrator class +/// + custom (or subclass ). +/// +/// +/// Stateless, simple per-reason logic, value output +/// lambda overload. +/// +/// +/// Output is a cache changeset, you mutate a ChangeAwareCache per source/inner event +/// . +/// +/// +/// Output is a merged cache changeset (inner observables themselves emit cache changesets) +/// (cache overload). +/// +/// +/// Output is a merged list changeset (inner observables emit list changesets) +/// (list overload). +/// +/// +/// +internal static partial class IntObservableCacheEx +{ + /// + /// Orchestrates a keyed source changeset and a dynamic set of per-key inner observables into a + /// single result stream. The supplied is invoked on every subscription + /// with the per-subscription and downstream + /// emitter; the orchestrator instance it returns owns its per-subscription state for the lifetime + /// of the subscription. + /// + /// Type of items in the source changeset. + /// Type of the source changeset key. + /// Type of values emitted by the per-key inner observables. + /// Type delivered downstream. + /// Concrete orchestrator type returned by the factory. Generic-typed so dispatch sites devirtualize. + /// The keyed source changeset stream. + /// Builds the per-subscription orchestrator from its runtime context and emitter. + /// An observable that orchestrates source and inner activity into a single result stream. + public static IObservable Orchestrate( + this IObservable> source, + Func, IObserver, TOrch> factory) + where TSource : notnull + where TKey : notnull + where TInner : notnull + where TOrch : ICacheOrchestrator => + new CacheOrchestration(source, factory).Run(); + + /// + /// Convenience overload of + /// for stateless orchestrators or those with state small enough to capture in closures. The + /// source-change lambda receives the runtime context (for Track/Untrack/ + /// Serialize); the inner lambda receives the downstream emitter directly. + /// + /// Type of items in the source changeset. + /// Type of the source changeset key. + /// Type of values emitted by the per-key inner observables. + /// Type delivered downstream. + /// The keyed source changeset stream. + /// Invoked for each source changeset with the runtime context. + /// Invoked for each value emitted by a tracked inner observable, with its key and the downstream emitter. + /// Optional. Invoked once per drain cycle to flush aggregated state to the emitter. Defaults to a no-op. + /// An observable that orchestrates source and inner activity into a single result stream. + public static IObservable Orchestrate( + this IObservable> source, + Action, ICacheOrchestratorContext> onSourceChangeSet, + Action> onInner, + Action>? onDrainComplete = null) + where TSource : notnull + where TKey : notnull + where TInner : notnull => + source.Orchestrate>( + (context, emitter) => new LambdaCacheOrchestrator(context, emitter, onSourceChangeSet, onInner, onDrainComplete)); + + internal sealed class LambdaCacheOrchestrator( + ICacheOrchestratorContext context, + IObserver emitter, + Action, ICacheOrchestratorContext> onSourceChangeSet, + Action> onInner, + Action>? onDrainComplete) + : ICacheOrchestrator + where TSource : notnull + where TKey : notnull + where TInner : notnull + { + public void OnSourceChangeSet(IChangeSet changes) => onSourceChangeSet(changes, context); + + public void OnInner(TInner value, TKey key) => onInner(value, key, emitter); + + public void OnDrainComplete(bool isFinal, bool wasReentrant) => onDrainComplete?.Invoke(emitter); + } +} diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs new file mode 100644 index 000000000..3963d7fec --- /dev/null +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs @@ -0,0 +1,85 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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.Linq; + +namespace DynamicData.Cache.Internal; + +internal static partial class IntObservableCacheEx +{ + /// + /// Orchestrates per-key inner observables that drive mutations to a shared + /// . Source events and inner emissions are + /// coalesced into a single downstream changeset per drain cycle. For the mirror-manipulator + /// shape (filter, per-key transform) where each source key contributes 0 or 1 items to the + /// output. + /// + /// Type of items in the source changeset. + /// Type of the source changeset key (also the output changeset key). + /// Value type emitted by the per-key inner observable. + /// Type of items in the output changeset. + /// The keyed source changeset stream. + /// Builds the per-key inner observable from the source item and its key. + /// Invoked once per source change with the output cache. The caller decides how the source event mutates the cache. Invoked before the corresponding inner subscription is created or torn down. + /// Invoked once per inner emission with the output cache, source key, source item, and emitted value. + /// An observable changeset where every emission is the captured changes from one drain cycle. + public static IObservable> OrchestrateChangeSets( + this IObservable> source, + Func> innerFactory, + Action, Change> onSourceChange, + Action, TKey, TSource, TInner> onInner) + where TSource : notnull + where TKey : notnull + where TInner : notnull + where TOutput : notnull => + source.Orchestrate, ChangeSetOrchestrator>( + (context, emitter) => new ChangeSetOrchestrator(context, emitter, innerFactory, onSourceChange, onInner)); + + internal sealed class ChangeSetOrchestrator( + ICacheOrchestratorContext context, + IObserver> emitter, + Func> innerFactory, + Action, Change> onSourceChange, + Action, TKey, TSource, TInner> onInner) + : CacheOrchestratorBase>(context, emitter) + where TSource : notnull + where TKey : notnull + where TInner : notnull + where TOutput : notnull + { + private readonly ChangeAwareCache _cache = new(); + + public override void OnInner((TSource Item, TInner Value) value, TKey key) => onInner(_cache, key, value.Item, value.Value); + + public override void OnDrainComplete(bool isFinal, bool wasReentrant) + { + var captured = _cache.CaptureChanges(); + if (captured.Count != 0) + { + Emitter.OnNext(captured); + } + } + + protected override void OnItemAdded(TSource item, TKey key) + { + onSourceChange(_cache, new Change(ChangeReason.Add, key, item)); + Context.Track(key, innerFactory(item, key).Select(value => (Item: item, Value: value))); + } + + protected override void OnItemUpdated(TSource current, TSource previous, TKey key) + { + onSourceChange(_cache, new Change(ChangeReason.Update, key, current, previous)); + Context.Track(key, innerFactory(current, key).Select(value => (Item: current, Value: value))); + } + + protected override void OnItemRemoved(TSource item, TKey key) + { + onSourceChange(_cache, new Change(ChangeReason.Remove, key, item)); + Context.Untrack(key); + } + + protected override void OnItemRefreshed(TSource item, TKey key) => + onSourceChange(_cache, new Change(ChangeReason.Refresh, key, item)); + } +} diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs new file mode 100644 index 000000000..08a68e8e3 --- /dev/null +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs @@ -0,0 +1,202 @@ +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// 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.Linq; +using DynamicData.List.Internal; + +namespace DynamicData.Cache.Internal; + +internal static partial class IntObservableCacheEx +{ + /// + /// Cache-shape overload. Per-source-key inner observables that emit cache changesets, merged + /// into a single output cache changeset. + /// + /// Type of items in the source changeset. + /// Type of the source changeset key. + /// Type of items in the output (merged) changeset. + /// Type of the output changeset key. + /// The keyed source changeset stream. + /// Builds the per-source-key child changeset stream from the source item and its key. + /// Optional equality comparer for the destination items. + /// Optional ordering comparer for the destination items. + /// When , a source Refresh forces re-evaluation of the corresponding child entries via the tracker. + /// An observable changeset representing the merged union of all live inner changesets. + public static IObservable> OrchestrateManyChangeSets( + this IObservable> source, + Func>> changeSetSelector, + IEqualityComparer? equalityComparer = null, + IComparer? comparer = null, + bool reevalOnRefresh = false) + where TSource : notnull + where TKey : notnull + where TDest : notnull + where TDestKey : notnull => + source.Orchestrate, IChangeSet, MergedOrchestrator>( + (context, emitter) => new MergedOrchestrator(context, emitter, changeSetSelector, equalityComparer, comparer, reevalOnRefresh)); + + /// + /// List-shape overload. Per-source-key inner observables that emit list changesets, merged + /// into a single output list changeset. + /// + /// Type of items in the source changeset. + /// Type of the source changeset key. + /// Type of items in the output (merged) list changeset. + /// The keyed source changeset stream. + /// Builds the per-source-key child list changeset stream from the source item and its key. + /// Optional equality comparer for per-source-key snapshots of inner contents. + /// An observable list changeset representing the merged union of all live inner changesets. + public static IObservable> OrchestrateManyChangeSets( + this IObservable> source, + Func>> changeSetSelector, + IEqualityComparer? equalityComparer = null) + where TSource : notnull + where TKey : notnull + where TDest : notnull => + source.Orchestrate, IChangeSet, MergedListOrchestrator>( + (context, emitter) => new MergedListOrchestrator(context, emitter, changeSetSelector, equalityComparer)); + + internal sealed class MergedOrchestrator : CacheOrchestratorBase, IChangeSet> + where TSource : notnull + where TKey : notnull + where TDest : notnull + where TDestKey : notnull + { + private readonly Cache, TKey> _cache = new(); + private readonly ChangeSetMergeTracker _tracker; + private readonly Func>> _changeSetSelector; + private readonly bool _reevalOnRefresh; + + public MergedOrchestrator( + ICacheOrchestratorContext> context, + IObserver> emitter, + Func>> changeSetSelector, + IEqualityComparer? equalityComparer, + IComparer? comparer, + bool reevalOnRefresh) + : base(context, emitter) + { + _changeSetSelector = changeSetSelector; + _reevalOnRefresh = reevalOnRefresh; + _tracker = new ChangeSetMergeTracker(() => _cache.Items, comparer, equalityComparer); + } + + public override void OnInner(IChangeSet child, TKey parentKey) + { + if (_cache.Lookup(parentKey) is { HasValue: true } entry) + { + entry.Value.Process(child); + } + + _tracker.ProcessChangeSet(child, null); + } + + public override void OnDrainComplete(bool isFinal, bool wasReentrant) => _tracker.EmitChanges(Emitter); + + protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); + + protected override void OnItemUpdated(TSource current, TSource previous, TKey key) + { + var prior = _cache.Lookup(key); + SubscribeChild(current, key); + if (prior.HasValue) + { + _tracker.RemoveItems(prior.Value.Cache.KeyValues); + } + } + + protected override void OnItemRemoved(TSource item, TKey key) + { + if (_cache.Lookup(key) is { HasValue: true } removed) + { + // Remove from _cache BEFORE telling the tracker, so the tracker's re-evaluation + // does not consider this entry's items as candidates for "best value" selection. + _cache.Remove(key); + _tracker.RemoveItems(removed.Value.Cache.KeyValues); + } + + Context.Untrack(key); + } + + protected override void OnItemRefreshed(TSource item, TKey key) + { + if (_reevalOnRefresh && _cache.Lookup(key) is { HasValue: true } current) + { + _tracker.RefreshItems(current.Value.Cache.Keys); + } + } + + private void SubscribeChild(TSource item, TKey key) + { + var entry = new ChangeSetCache(_changeSetSelector(item, key).IgnoreSameReferenceUpdate()); + _cache.AddOrUpdate(entry, key); + Context.Track(key, entry.Source); + } + } + + internal sealed class MergedListOrchestrator : CacheOrchestratorBase, IChangeSet> + where TSource : notnull + where TKey : notnull + where TDest : notnull + { + private readonly Dictionary> _entries = new(); + private readonly ChangeSetMergeTracker _tracker = new(); + private readonly Func>> _changeSetSelector; + private readonly IEqualityComparer? _equalityComparer; + + public MergedListOrchestrator( + ICacheOrchestratorContext> context, + IObserver> emitter, + Func>> changeSetSelector, + IEqualityComparer? equalityComparer) + : base(context, emitter) + { + _changeSetSelector = changeSetSelector; + _equalityComparer = equalityComparer; + } + + public override void OnInner(IChangeSet child, TKey parentKey) + { + if (_entries.TryGetValue(parentKey, out var entry)) + { + entry.Process(child); + } + + _tracker.ProcessChangeSet(child, null); + } + + public override void OnDrainComplete(bool isFinal, bool wasReentrant) => _tracker.EmitChanges(Emitter); + + protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); + + protected override void OnItemUpdated(TSource current, TSource previous, TKey key) + { + if (_entries.TryGetValue(key, out var prior)) + { + _entries.Remove(key); + _tracker.RemoveItems(prior.List); + } + + SubscribeChild(current, key); + } + + protected override void OnItemRemoved(TSource item, TKey key) + { + if (_entries.TryGetValue(key, out var removed)) + { + _entries.Remove(key); + _tracker.RemoveItems(removed.List); + } + + Context.Untrack(key); + } + + private void SubscribeChild(TSource item, TKey key) + { + var entry = new ClonedListChangeSet(_changeSetSelector(item, key).RemoveIndex(), _equalityComparer); + _entries[key] = entry; + Context.Track(key, entry.Source); + } + } +} diff --git a/src/DynamicData/Cache/Internal/MergeChangeSets.cs b/src/DynamicData/Cache/Internal/MergeChangeSets.cs index ebf4b22f1..b9ef506c9 100644 --- a/src/DynamicData/Cache/Internal/MergeChangeSets.cs +++ b/src/DynamicData/Cache/Internal/MergeChangeSets.cs @@ -33,7 +33,7 @@ public IObservable> Run() => Observable.Create mc.Source.Do(static _ => { }, observer.OnError)) + .MergeMany(mc => mc.Source.Do(mc.Process)) .SubscribeSafe( changes => changeTracker.ProcessChangeSet(changes, observer), observer.OnError, diff --git a/src/DynamicData/Cache/Internal/MergeMany.cs b/src/DynamicData/Cache/Internal/MergeMany.cs index fe2eecc75..635973017 100644 --- a/src/DynamicData/Cache/Internal/MergeMany.cs +++ b/src/DynamicData/Cache/Internal/MergeMany.cs @@ -1,11 +1,7 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. // 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.Disposables; -using System.Reactive.Linq; -using System.Runtime.CompilerServices; - namespace DynamicData.Cache.Internal; internal sealed class MergeMany @@ -13,7 +9,6 @@ internal sealed class MergeMany where TKey : notnull { private readonly Func> _observableSelector; - private readonly IObservable> _source; public MergeMany(IObservable> source, Func> observableSelector) @@ -30,32 +25,21 @@ public MergeMany(IObservable> source, Func observableSelector(t); } - public IObservable Run() => Observable.Create( - observer => + public IObservable Run() => + _source.Orchestrate( + onSourceChangeSet: (changes, context) => { - var counter = new StrongBox(1); - var queue = new DeliveryQueue(observer); - - // Queue first: terminate before subscription disposal to prevent - // Finally callbacks from delivering spurious OnCompleted during teardown. - return new CompositeDisposable(queue, _source - .Do(static _ => { }, static _ => { }, () => CheckCompleted(counter, queue)) - .Concat(Observable.Never>()) - .SubscribeMany((t, key) => + foreach (var change in changes.ToConcreteType()) + { + if (change.Reason is ChangeReason.Add or ChangeReason.Update) { - Interlocked.Increment(ref counter.Value); - return _observableSelector(t, key) - .Finally(() => CheckCompleted(counter, queue)) - .Subscribe(queue.OnNext, static _ => { }); - }) - .Subscribe(static _ => { }, observer.OnError)); - }); - - private static void CheckCompleted(StrongBox counter, DeliveryQueue queue) - { - if (Interlocked.Decrement(ref counter.Value) == 0 && !queue.IsTerminated) - { - queue.OnCompleted(); - } - } + context.Track(change.Key, _observableSelector(change.Current, change.Key)); + } + else if (change.Reason is ChangeReason.Remove) + { + context.Untrack(change.Key); + } + } + }, + onInner: (value, _, emitter) => emitter.OnNext(value)); } diff --git a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSets.cs b/src/DynamicData/Cache/Internal/MergeManyCacheChangeSets.cs deleted file mode 100644 index 551cc8bbf..000000000 --- a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSets.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. -// 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.Linq; -using DynamicData.Internal; - -namespace DynamicData.Cache.Internal; - -/// -/// Operator that is similiar to MergeMany but intelligently handles Cache ChangeSets. -/// -internal sealed class MergeManyCacheChangeSets(IObservable> source, Func>> changeSetSelector, IEqualityComparer? equalityComparer, IComparer? comparer) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull -{ - public IObservable> Run() => Observable.Create>( - observer => new Subscription(source, changeSetSelector, observer, equalityComparer, comparer)); - - // Maintains state for a single subscription - private sealed class Subscription : CacheParentSubscription, TKey, IChangeSet, IChangeSet> - { - private readonly Cache, TKey> _cache = new(); - private readonly ChangeSetMergeTracker _changeSetMergeTracker; - - public Subscription( - IObservable> source, - Func>> changeSetSelector, - IObserver> observer, - IEqualityComparer? equalityComparer, - IComparer? comparer) - : base(observer) - { - _changeSetMergeTracker = new(() => _cache.Items, comparer, equalityComparer); - - // Child Observable has to go into the ChangeSetCache so the locking protects it - CreateParentSubscription(source.Transform((obj, key) => - new ChangeSetCache(MakeChildObservable(changeSetSelector(obj, key).IgnoreSameReferenceUpdate())))); - } - - protected override void ParentOnNext(IChangeSet, TKey> changes) - { - // Process all the changes at once to preserve the changeset order - foreach (var change in changes.ToConcreteType()) - { - switch (change.Reason) - { - // Shutdown existing sub (if any) and create a new one that - // Will update the cache and emit the changes - case ChangeReason.Add or ChangeReason.Update: - _cache.AddOrUpdate(change.Current, change.Key); - AddChildSubscription(change.Current.Source, change.Key); - if (change.Previous.HasValue) - { - _changeSetMergeTracker.RemoveItems(change.Previous.Value.Cache.KeyValues); - } - break; - - // Shutdown the existing subscription and remove from the cache - case ChangeReason.Remove: - _cache.Remove(change.Key); - RemoveChildSubscription(change.Key); - _changeSetMergeTracker.RemoveItems(change.Current.Cache.KeyValues); - break; - } - } - } - - protected override void ChildOnNext(IChangeSet changes, TKey parentKey) => - _changeSetMergeTracker.ProcessChangeSet(changes, null); - - protected override void EmitChanges(IObserver> observer) => - _changeSetMergeTracker.EmitChanges(observer); - } -} diff --git a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs b/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs deleted file mode 100644 index a49ad8223..000000000 --- a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. -// 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.Linq; -using DynamicData.Internal; - -namespace DynamicData.Cache.Internal; - -/// -/// Alternate version of MergeManyCacheChangeSets that uses a Comparer of the source, not the destination type -/// So that items from the most important source go into the resulting changeset. -/// -internal sealed class MergeManyCacheChangeSetsSourceCompare(IObservable> source, Func>> selector, IComparer parentCompare, IEqualityComparer? equalityComparer, IComparer? childCompare, bool reevalOnRefresh = false) - where TObject : notnull - where TKey : notnull - where TDestination : notnull - where TDestinationKey : notnull -{ - private readonly Func>> _changeSetSelector = (obj, key) => selector(obj, key).Transform(dest => new ParentChildEntry(obj, dest)); - - private readonly IComparer _comparer = (childCompare is null) ? new ParentOnlyCompare(parentCompare) : new ParentChildCompare(parentCompare, childCompare); - - private readonly IEqualityComparer? _equalityComparer = (equalityComparer != null) ? new ParentChildEqualityCompare(equalityComparer) : null; - - public IObservable> Run() => - Observable.Create>(observer => new Subscription(source, _changeSetSelector, observer, _comparer, _equalityComparer, reevalOnRefresh)) - .TransformImmutable(entry => entry.Child); - - // Maintains state for a single subscription - private sealed class Subscription : CacheParentSubscription, TKey, IChangeSet, IChangeSet> - { - private readonly Cache, TKey> _cache = new(); - private readonly ChangeSetMergeTracker _changeSetMergeTracker; - private readonly bool _reevalOnRefresh; - - public Subscription( - IObservable> source, - Func>> changeSetSelector, - IObserver> observer, - IComparer comparer, - IEqualityComparer? equalityComparer, - bool reevalOnRefresh) - : base(observer) - { - _changeSetMergeTracker = new(() => _cache.Items, comparer, equalityComparer); - _reevalOnRefresh = reevalOnRefresh; - - // Child Observable has to go into the ChangeSetCache so the locking protects it - CreateParentSubscription(source.Transform((obj, key) => - new ChangeSetCache(MakeChildObservable(changeSetSelector(obj, key).IgnoreSameReferenceUpdate())))); - } - - protected override void ParentOnNext(IChangeSet, TKey> changes) - { - // Process all the changes at once to preserve the changeset order - foreach (var change in changes.ToConcreteType()) - { - switch (change.Reason) - { - // Shutdown existing sub (if any) and create a new one that - // Will update the cache and emit the changes - case ChangeReason.Add or ChangeReason.Update: - _cache.AddOrUpdate(change.Current, change.Key); - AddChildSubscription(change.Current.Source, change.Key); - if (change.Previous.HasValue) - { - _changeSetMergeTracker.RemoveItems(change.Previous.Value.Cache.KeyValues); - } - break; - - // Shutdown the existing subscription and remove from the cache - case ChangeReason.Remove: - _cache.Remove(change.Key); - RemoveChildSubscription(change.Key); - _changeSetMergeTracker.RemoveItems(change.Current.Cache.KeyValues); - break; - - case ChangeReason.Refresh: - if (_reevalOnRefresh) - { - _changeSetMergeTracker.RefreshItems(change.Current.Cache.Keys); - } - break; - } - } - } - - protected override void ChildOnNext(IChangeSet changes, TKey parentKey) => - _changeSetMergeTracker.ProcessChangeSet(changes, null); - - protected override void EmitChanges(IObserver> observer) => - _changeSetMergeTracker.EmitChanges(observer); - } - - private sealed record ParentChildEntry(TObject Parent, TDestination Child); - - private sealed class ParentChildCompare(IComparer comparerParent, IComparer comparerChild) : Comparer - { - public override int Compare(ParentChildEntry? x, ParentChildEntry? y) => (x, y) switch - { - (not null, not null) => comparerParent.Compare(x.Parent, y.Parent) switch - { - 0 => comparerChild.Compare(x.Child, y.Child), - int i => i, - }, - (null, null) => 0, - (null, not null) => 1, - (not null, null) => -1, - }; - } - - private sealed class ParentOnlyCompare(IComparer comparer) : Comparer - { - public override int Compare(ParentChildEntry? x, ParentChildEntry? y) => (x, y) switch - { - (not null, not null) => comparer.Compare(x.Parent, y.Parent), - (null, null) => 0, - (null, not null) => 1, - (not null, null) => -1, - }; - } - - private sealed class ParentChildEqualityCompare(IEqualityComparer comparer) : EqualityComparer - { - public override bool Equals(ParentChildEntry? x, ParentChildEntry? y) => (x, y) switch - { - (not null, not null) => comparer.Equals(x.Child, y.Child), - (null, null) => true, - _ => false, - }; - - public override int GetHashCode(ParentChildEntry obj) => comparer.GetHashCode(obj.Child); - } -} diff --git a/src/DynamicData/Cache/Internal/MergeManyItems.cs b/src/DynamicData/Cache/Internal/MergeManyItems.cs index 26b85614b..3486fafde 100644 --- a/src/DynamicData/Cache/Internal/MergeManyItems.cs +++ b/src/DynamicData/Cache/Internal/MergeManyItems.cs @@ -11,7 +11,6 @@ internal sealed class MergeManyItems where TKey : notnull { private readonly Func> _observableSelector; - private readonly IObservable> _source; public MergeManyItems(IObservable> source, Func> observableSelector) @@ -22,14 +21,28 @@ public MergeManyItems(IObservable> source, Func> source, Func> observableSelector) { - if (observableSelector is null) - { - throw new ArgumentNullException(nameof(observableSelector)); - } + observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); _source = source ?? throw new ArgumentNullException(nameof(source)); _observableSelector = (t, _) => observableSelector(t); } - public IObservable> Run() => Observable.Create>(observer => _source.SubscribeMany((t, v) => _observableSelector(t, v).Select(z => new ItemWithValue(t, z)).SubscribeSafe(observer)).Subscribe()); + public IObservable> Run() => + _source.Orchestrate>( + onSourceChangeSet: (changes, context) => + { + foreach (var change in changes.ToConcreteType()) + { + if (change.Reason is ChangeReason.Add or ChangeReason.Update) + { + var item = change.Current; + context.Track(change.Key, _observableSelector(item, change.Key).Select(value => (Item: item, Value: value))); + } + else if (change.Reason is ChangeReason.Remove) + { + context.Untrack(change.Key); + } + } + }, + onInner: (value, _, emitter) => emitter.OnNext(new ItemWithValue(value.Item, value.Value))); } diff --git a/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs b/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs deleted file mode 100644 index bd7b18f4d..000000000 --- a/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. -// 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.Linq; -using DynamicData.Internal; -using DynamicData.List.Internal; - -namespace DynamicData.Cache.Internal; - -/// -/// Operator that is similiar to MergeMany but intelligently handles List ChangeSets. -/// -internal sealed class MergeManyListChangeSets(IObservable> source, Func>> selector, IEqualityComparer? equalityComparer) - where TObject : notnull - where TKey : notnull - where TDestination : notnull -{ - public IObservable> Run() => Observable.Create>( - observer => new Subscription(source, selector, observer, equalityComparer)); - - // Maintains state for a single subscription - private sealed class Subscription : CacheParentSubscription, TKey, IChangeSet, IChangeSet> - { - private readonly ChangeSetMergeTracker _changeSetMergeTracker = new(); - - public Subscription( - IObservable> source, - Func>> selector, - IObserver> observer, - IEqualityComparer? equalityComparer) - : base(observer) - { - // RemoveIndex outside of the Lock, but add locking before going to ClonedChangeSet so the contents are protected - CreateParentSubscription(source.Transform((obj, key) => - new ClonedListChangeSet(MakeChildObservable(selector(obj, key).RemoveIndex()), equalityComparer))); - } - - protected override void ParentOnNext(IChangeSet, TKey> changes) - { - // Process all the changes at once to preserve the changeset order - foreach (var change in changes.ToConcreteType()) - { - switch (change.Reason) - { - // Shutdown existing sub (if any) and create a new one - // Remove any items from the previous list - case ChangeReason.Add or ChangeReason.Update: - AddChildSubscription(change.Current.Source, change.Key); - if (change.Previous.HasValue) - { - _changeSetMergeTracker.RemoveItems(change.Previous.Value.List); - } - break; - - // Shutdown the existing subscription and remove from the cache - case ChangeReason.Remove: - RemoveChildSubscription(change.Key); - _changeSetMergeTracker.RemoveItems(change.Current.List); - break; - } - } - } - - protected override void ChildOnNext(IChangeSet child, TKey parentKey) => - _changeSetMergeTracker.ProcessChangeSet(child, null); - - protected override void EmitChanges(IObserver> observer) => - _changeSetMergeTracker.EmitChanges(observer); - } -} diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index b202cb307..70aaf0b39 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -1,94 +1,103 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. // 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.Linq; -using DynamicData.Internal; +using DynamicData.Cache; namespace DynamicData.Cache.Internal; -internal sealed class TransformManyAsync(IObservable> source, Func>>> transformer, IEqualityComparer? equalityComparer, IComparer? comparer, Action>? errorHandler = null) +internal sealed class TransformManyAsync( + IObservable> source, + Func>>> transformer, + IEqualityComparer? equalityComparer, + IComparer? comparer, + Action>? errorHandler = null) where TSource : notnull where TKey : notnull where TDestination : notnull where TDestinationKey : notnull { - public IObservable> Run() => Observable.Create>( - observer => new Subscription(source, transformer, observer, equalityComparer, comparer, errorHandler)); + public IObservable> Run() => + source.Orchestrate, IChangeSet, Orchestrator>( + (context, emitter) => new Orchestrator(context, emitter, transformer, equalityComparer, comparer, errorHandler)); - // Maintains state for a single subscription - private sealed class Subscription : CacheParentSubscription, TKey, IChangeSet, IChangeSet> + internal sealed class Orchestrator : CacheOrchestratorBase, IChangeSet> { private readonly Cache, TKey> _cache = new(); - private readonly ChangeSetMergeTracker _changeSetMergeTracker; + private readonly ChangeSetMergeTracker _tracker; + private readonly Func>>> _transformer; + private readonly Action>? _errorHandler; - public Subscription(IObservable> source, Func>>> transform, IObserver> observer, IEqualityComparer? equalityComparer, IComparer? comparer, Action>? errorHandler = null) - : base(observer) + public Orchestrator( + ICacheOrchestratorContext> context, + IObserver> emitter, + Func>>> transformer, + IEqualityComparer? equalityComparer, + IComparer? comparer, + Action>? errorHandler) + : base(context, emitter) { - // Transform Helper - async Task>> ErrorHandlingTransform(TSource obj, TKey key) + _transformer = transformer; + _errorHandler = errorHandler; + _tracker = new ChangeSetMergeTracker(() => _cache.Items, comparer, equalityComparer); + } + + public override void OnInner(IChangeSet child, TKey parentKey) + { + if (_cache.Lookup(parentKey) is { HasValue: true } entry) { - try - { - return await transform(obj, key).ConfigureAwait(false); - } - catch (Exception e) - { - errorHandler.Invoke(new Error(e, obj, key)); - return Observable.Empty>(); - } + entry.Value.Process(child); } - ChangeSetCache Transformer(TSource obj, TKey key) => - new(MakeChildObservable(Observable.Defer(() => transform(obj, key)))); + _tracker.ProcessChangeSet(child, null); + } - ChangeSetCache SafeTransformer(TSource obj, TKey key) => - new(MakeChildObservable(Observable.Defer(() => ErrorHandlingTransform(obj, key)))); + public override void OnDrainComplete(bool isFinal, bool wasReentrant) => _tracker.EmitChanges(Emitter); - _changeSetMergeTracker = new(() => _cache.Items, comparer, equalityComparer); + protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); - if (errorHandler is null) - { - CreateParentSubscription(source.Transform(Transformer)); - } - else + protected override void OnItemUpdated(TSource current, TSource previous, TKey key) + { + var prior = _cache.Lookup(key); + SubscribeChild(current, key); + if (prior.HasValue) { - CreateParentSubscription(source.Transform(SafeTransformer)); + _tracker.RemoveItems(prior.Value.Cache.KeyValues); } } - protected override void ParentOnNext(IChangeSet, TKey> changes) + protected override void OnItemRemoved(TSource item, TKey key) { - // Process all the changes at once to preserve the changeset order - foreach (var change in changes.ToConcreteType()) + if (_cache.Lookup(key) is { HasValue: true } removed) { - switch (change.Reason) - { - // Shutdown existing sub (if any) and create a new one that - // Will update the cache and emit the changes - case ChangeReason.Add or ChangeReason.Update: - _cache.AddOrUpdate(change.Current, change.Key); - AddChildSubscription(change.Current.Source, change.Key); - if (change.Previous.HasValue) - { - _changeSetMergeTracker.RemoveItems(change.Previous.Value.Cache.KeyValues); - } - break; - - // Shutdown the existing subscription and remove from the cache - case ChangeReason.Remove: - _cache.Remove(change.Key); - RemoveChildSubscription(change.Key); - _changeSetMergeTracker.RemoveItems(change.Current.Cache.KeyValues); - break; - } + _cache.Remove(key); + _tracker.RemoveItems(removed.Value.Cache.KeyValues); } + + Context.Untrack(key); } - protected override void ChildOnNext(IChangeSet child, TKey parentKey) => - _changeSetMergeTracker.ProcessChangeSet(child); + private void SubscribeChild(TSource item, TKey key) + { + var entry = new ChangeSetCache(BuildInner(item, key)); + _cache.AddOrUpdate(entry, key); + Context.Track(key, entry.Source); + } - protected override void EmitChanges(IObserver> observer) => - _changeSetMergeTracker.EmitChanges(observer); + private IObservable> BuildInner(TSource obj, TKey key) => _errorHandler is null + ? Observable.Defer(() => _transformer(obj, key)) + : Observable.Defer(async () => + { + try + { + return await _transformer(obj, key).ConfigureAwait(false); + } + catch (Exception e) + { + _errorHandler(new Error(e, obj, key)); + return Observable.Empty>(); + } + }); } } diff --git a/src/DynamicData/Cache/Internal/TransformOnObservable.cs b/src/DynamicData/Cache/Internal/TransformOnObservable.cs index b88e4f1c6..54d43152d 100644 --- a/src/DynamicData/Cache/Internal/TransformOnObservable.cs +++ b/src/DynamicData/Cache/Internal/TransformOnObservable.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. +// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. @@ -12,71 +12,49 @@ internal sealed class TransformOnObservable(IObserv where TKey : notnull where TDestination : notnull { - public IObservable> Run() => - Observable.Create>(observer => new Subscription(source, transform, observer, transformOnRefresh)); - - // Maintains state for a single subscription - private sealed class Subscription : CacheParentSubscription> + public IObservable> Run() => Observable.Defer(() => { - private readonly ChangeAwareCache _cache = new(); - private readonly Func> _transform; - private readonly bool _transformOnRefresh; - - public Subscription(IObservable> source, Func> transform, IObserver> observer, bool transformOnRefresh) - : base(observer) - { - _transform = transform; - _transformOnRefresh = transformOnRefresh; - CreateParentSubscription(source); - } + var cache = new ChangeAwareCache(); - protected override void ParentOnNext(IChangeSet changes) - { - // Process all the changes at once to preserve the changeset order - foreach (var change in changes.ToConcreteType()) + return source.Orchestrate>( + onSourceChangeSet: (changes, context) => { - switch (change.Reason) + foreach (var change in changes.ToConcreteType()) { - // Shutdown existing sub (if any) and create a new one that - // Will update the cache and emit the changes - case ChangeReason.Add or ChangeReason.Update: - AddTransformSubscription(change.Current, change.Key); - break; - - // Shutdown the existing subscription and remove from the cache - case ChangeReason.Remove: - _cache.Remove(change.Key); - RemoveChildSubscription(change.Key); - break; - - case ChangeReason.Refresh: - if (_transformOnRefresh) - { - AddTransformSubscription(change.Current, change.Key); - } - else - { - // Let the downstream decide what this means - _cache.Refresh(change.Key); - } - break; + switch (change.Reason) + { + case ChangeReason.Add or ChangeReason.Update: + context.Track(change.Key, transform(change.Current, change.Key).DistinctUntilChanged()); + break; + + case ChangeReason.Remove: + cache.Remove(change.Key); + context.Untrack(change.Key); + break; + + case ChangeReason.Refresh: + if (transformOnRefresh) + { + context.Track(change.Key, transform(change.Current, change.Key).DistinctUntilChanged()); + } + else + { + // Let the downstream decide what this means. + cache.Refresh(change.Key); + } + + break; + } } - } - } - - protected override void ChildOnNext(TDestination child, TKey parentKey) => - _cache.AddOrUpdate(child, parentKey); - - protected override void EmitChanges(IObserver> observer) - { - var changes = _cache.CaptureChanges(); - if (changes.Count > 0) + }, + onInner: (value, key, _) => cache.AddOrUpdate(value, key), + onDrainComplete: observer => { - observer.OnNext(changes); - } - } - - private void AddTransformSubscription(TSource obj, TKey key) => - AddChildSubscription(MakeChildObservable(_transform(obj, key).DistinctUntilChanged()), key); - } + var captured = cache.CaptureChanges(); + if (captured.Count != 0) + { + observer.OnNext(captured); + } + }); + }); } diff --git a/src/DynamicData/Cache/ObservableCache.cs b/src/DynamicData/Cache/ObservableCache.cs index 5cbeca988..ed1bd905b 100644 --- a/src/DynamicData/Cache/ObservableCache.cs +++ b/src/DynamicData/Cache/ObservableCache.cs @@ -479,7 +479,6 @@ public void SuspendNotifications() if (++_notifySuspendCount == 1) { Debug.Assert(_pendingChanges.Count == 0, "Shouldn't be any pending values if suspend was just started"); - Debug.Assert(!_areNotificationsSuspended.Value, "SuspendSubject should be false for the first suspend call"); _areNotificationsSuspended.OnNext(true); } } diff --git a/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs b/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs index 1e7bfb428..aeb9e385d 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.AutoRefresh.cs @@ -73,6 +73,7 @@ public static IObservable> AutoRefresh diff --git a/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs b/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs index 39a04294e..2b6eff2e5 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs @@ -39,7 +39,13 @@ public static partial class ObservableCacheEx /// public static IObservable> AutoRefreshOnObservable(this IObservable> source, Func> reevaluator, TimeSpan? changeSetBuffer = null, IScheduler? scheduler = null) where TObject : notnull - where TKey : notnull => source.AutoRefreshOnObservable((t, _) => reevaluator(t), changeSetBuffer, scheduler); + where TKey : notnull + { + source.ThrowArgumentNullExceptionIfNull(nameof(source)); + reevaluator.ThrowArgumentNullExceptionIfNull(nameof(reevaluator)); + + return source.AutoRefreshOnObservable((t, _) => reevaluator(t), changeSetBuffer, scheduler); + } /// /// Automatically refresh downstream operator. The refresh is triggered when the observable receives a notification. @@ -53,7 +59,7 @@ public static IObservable> AutoRefreshOnObservableAn optional for scheduling work. /// An observable change set with additional refresh changes. /// - /// Worth noting: Per-item observable errors are silently ignored (not forwarded to the downstream observer). Only source stream errors propagate. + /// Worth noting: Per-item observable errors terminate the output stream. /// public static IObservable> AutoRefreshOnObservable(this IObservable> source, Func> reevaluator, TimeSpan? changeSetBuffer = null, IScheduler? scheduler = null) where TObject : notnull diff --git a/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs b/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs index b3c290d34..094ae562f 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs @@ -49,7 +49,7 @@ public static partial class ObservableCacheEx /// UpdateDisposes the previous child subscription and creates a new one for the updated item. /// RemoveDisposes the child subscription for the removed item. /// RefreshNo effect on subscriptions. The child observable continues unchanged. - /// OnErrorErrors from child observables are silently swallowed (the child is unsubscribed). Errors from the source changeset stream terminate the merged output. + /// OnErrorErrors from child observables terminate the merged output stream with that error. Errors from the source changeset stream also terminate the merged output. /// /// Worth noting: The output is a plain , not a changeset stream. If you need merged changesets, use instead. /// diff --git a/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs index 5fcbb9ed0..659c94f57 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs @@ -148,7 +148,7 @@ public static IObservable> MergeManyCh /// /// /// EventBehavior - /// OnErrorAn error from the source (parent) stream or from any child changeset stream terminates the entire output. Unlike , child errors are NOT swallowed. + /// OnErrorAn error from the source (parent) stream or from any child changeset stream terminates the entire output. /// OnCompletedThe output completes when the source (parent) stream completes and all active child changeset streams have also completed. /// /// @@ -172,7 +172,7 @@ public static IObservable> MergeManyCh source.ThrowArgumentNullExceptionIfNull(nameof(source)); observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); - return new MergeManyCacheChangeSets(source, observableSelector, equalityComparer, comparer).Run(); + return source.OrchestrateManyChangeSets(observableSelector, equalityComparer, comparer); } /// @@ -387,7 +387,23 @@ public static IObservable> MergeManyCh observableSelector.ThrowArgumentNullExceptionIfNull(nameof(observableSelector)); sourceComparer.ThrowArgumentNullExceptionIfNull(nameof(sourceComparer)); - return new MergeManyCacheChangeSetsSourceCompare(source, observableSelector, sourceComparer, equalityComparer, childComparer, resortOnSourceRefresh).Run(); + // Wrap each destination with its source parent so we can compare on the parent first, then + // unwrap back to plain destinations downstream. Supporting types are private nested types + // at the bottom of this file. + IComparer> entryComparer = childComparer is null + ? new ParentOnlyCompare(sourceComparer) + : new ParentChildCompare(sourceComparer, childComparer); + IEqualityComparer>? entryEqualityComparer = equalityComparer is null + ? null + : new ParentChildEqualityCompare(equalityComparer); + + return source + .OrchestrateManyChangeSets, TDestinationKey>( + changeSetSelector: (obj, key) => observableSelector(obj, key).Transform(dest => new ParentChildEntry(obj, dest)), + equalityComparer: entryEqualityComparer, + comparer: entryComparer, + reevalOnRefresh: resortOnSourceRefresh) + .TransformImmutable(entry => entry.Child); } /// @@ -410,7 +426,7 @@ public static IObservable> MergeManyChangeSets(source, observableSelector, equalityComparer).Run(); + return source.OrchestrateManyChangeSets(observableSelector, equalityComparer); } /// @@ -434,4 +450,54 @@ public static IObservable> MergeManyChangeSets(TObject Parent, TDestination Child) + where TObject : notnull + where TDestination : notnull; + + private sealed class ParentChildCompare(IComparer comparerParent, IComparer comparerChild) : Comparer> + where TObject : notnull + where TDestination : notnull + { + public override int Compare(ParentChildEntry? x, ParentChildEntry? y) => (x, y) switch + { + (not null, not null) => comparerParent.Compare(x.Parent, y.Parent) switch + { + 0 => comparerChild.Compare(x.Child, y.Child), + int i => i, + }, + (null, null) => 0, + (null, not null) => 1, + (not null, null) => -1, + }; + } + + private sealed class ParentOnlyCompare(IComparer comparer) : Comparer> + where TObject : notnull + where TDestination : notnull + { + public override int Compare(ParentChildEntry? x, ParentChildEntry? y) => (x, y) switch + { + (not null, not null) => comparer.Compare(x.Parent, y.Parent), + (null, null) => 0, + (null, not null) => 1, + (not null, null) => -1, + }; + } + + private sealed class ParentChildEqualityCompare(IEqualityComparer comparer) : EqualityComparer> + where TObject : notnull + where TDestination : notnull + { + public override bool Equals(ParentChildEntry? x, ParentChildEntry? y) => (x, y) switch + { + (not null, not null) => comparer.Equals(x.Child, y.Child), + (null, null) => true, + _ => false, + }; + + public override int GetHashCode(ParentChildEntry obj) => comparer.GetHashCode(obj.Child); + } } diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs index 15c3196dc..489cfa9c2 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs @@ -37,8 +37,7 @@ public static partial class ObservableCacheEx /// /// /// Subscriptions are managed per item: created on Add, replaced on Update, disposed on Remove. - /// Errors from individual property subscriptions are silently ignored. The output is not a changeset - /// stream; it is a plain IObservable<TObject?>. If the same item changes multiple properties + /// The output is not a changeset stream; it is a plain IObservable<TObject?>. If the same item changes multiple properties /// rapidly, each change emits the item separately (no deduplication). /// /// @@ -47,7 +46,7 @@ public static partial class ObservableCacheEx /// UpdateDisposes the old item's subscription and subscribes to the new item. /// RemoveDisposes the item's PropertyChanged subscription. /// RefreshNo effect on subscriptions. - /// OnErrorErrors from individual property subscriptions are silently ignored. Source errors terminate the stream. + /// OnErrorErrors from any item's property subscription terminate the output stream. Source errors also terminate the stream. /// /// /// diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs index 9efbb2791..c849ad748 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs @@ -38,9 +38,8 @@ public static partial class ObservableCacheEx /// An observable of containing both the item and its property value. /// /// - /// Per-item subscriptions are created on Add, replaced on Update, disposed on Remove. Errors from individual - /// property subscriptions are silently ignored. The output is not a changeset stream. If you only need - /// the value (not the owning item), use instead. + /// Per-item subscriptions are created on Add, replaced on Update, disposed on Remove. The output is not a + /// changeset stream. If you only need the value (not the owning item), use instead. /// /// /// EventBehavior @@ -48,7 +47,7 @@ public static partial class ObservableCacheEx /// UpdateDisposes the old item's property subscription and subscribes to the new item. /// RemoveDisposes the item's property subscription. No further emissions for this item. /// RefreshNo effect on subscriptions. The existing property subscription continues. - /// OnErrorPer-item property subscription errors are silently ignored. Source errors terminate the stream. + /// OnErrorErrors from any item's property subscription terminate the output stream. Source errors also terminate the stream. /// /// /// diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs index b27e31c4f..202b90d6e 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs @@ -38,9 +38,8 @@ public static partial class ObservableCacheEx /// An observable of property values. The owning item is not included; use if you need it. /// /// - /// Per-item subscriptions are created on Add, replaced on Update, disposed on Remove. Errors from individual - /// property subscriptions are silently ignored. If you need to correlate a value back to its source item, - /// use which returns a pair. + /// Per-item subscriptions are created on Add, replaced on Update, disposed on Remove. If you need to correlate a value + /// back to its source item, use which returns a pair. /// /// /// EventBehavior @@ -48,7 +47,7 @@ public static partial class ObservableCacheEx /// UpdateDisposes the old subscription, subscribes to the new item's property. /// RemoveDisposes the property subscription. /// RefreshNo effect on subscriptions. - /// OnErrorPer-item errors silently ignored. Source errors terminate the stream. + /// OnErrorErrors from any item's property subscription terminate the output stream. Source errors also terminate the stream. /// /// /// diff --git a/src/DynamicData/Internal/CacheParentSubscription.cs b/src/DynamicData/Internal/CacheParentSubscription.cs deleted file mode 100644 index 3a33143df..000000000 --- a/src/DynamicData/Internal/CacheParentSubscription.cs +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved. -// 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.Diagnostics; -using System.Reactive.Disposables; -using System.Reactive.Linq; - -namespace DynamicData.Internal; - -/// -/// Base class for subscriptions that need to manage child subscriptions and emit updates -/// when either the parent or child gets a new value. -/// Uses a for serialization and lock-free delivery. -/// Same-thread reentrant delivery preserves child-during-parent ordering. -/// OnDrainComplete calls EmitChanges after the outermost delivery, outside the lock. -/// -/// Type of the Parent ChangeSet. -/// Type for the Parent ChangeSet Key. -/// Type for the Child Subscriptions. -/// Type for the Final Observable. -internal abstract class CacheParentSubscription : IDisposable - where TParent : notnull - where TKey : notnull - where TChild : notnull -{ - private readonly KeyedDisposable _childSubscriptions = new(); - private readonly SingleAssignmentDisposable _parentSubscription = new(); - private readonly SharedDeliveryQueue _queue; - private readonly IObserver _observer; - private int _subscriptionCounter = 1; // Starts at 1 for the parent subscription - private bool _isCompleted; - private bool _hasTerminated; - private bool _disposedValue; - - /// - /// Initializes a new instance of the class. - /// - /// Observer to use for emitting events. - protected CacheParentSubscription(IObserver observer) - { - _observer = observer; - _queue = new SharedDeliveryQueue(onDrainComplete: OnDrainComplete); - } - - /// - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - protected abstract void ParentOnNext(IChangeSet changes); - - protected abstract void ChildOnNext(TChild child, TKey parentKey); - - protected abstract void EmitChanges(IObserver observer); - - protected void AddChildSubscription(IObservable observable, TKey parentKey) - { - // Add a new subscription. Do first so cleanup of existing subs doesn't trigger OnCompleted. - Interlocked.Increment(ref _subscriptionCounter); - - // Create a container for the Disposable and add to the KeyedDisposable - var disposableContainer = _childSubscriptions.Add(parentKey, new SingleAssignmentDisposable()); - - // Create the subscription - // Will Dispose immediately if OnCompleted fires upon subscription because OnCompleted disposes the container - // Remove the child subscription if it completes because its not needed anymore - // - // THREADING INVARIANT: Finally(CheckCompleted) fires on completion, error, AND disposal, - // ensuring the subscription counter always decrements. The onCompleted callback only fires - // on normal completion (not disposal), so RemoveChildSubscription is NOT called when the - // parent disposes child subscriptions during Dispose(). This asymmetry is intentional: - // disposal cleanup is handled by KeyedDisposable, not by individual completion callbacks. - disposableContainer.Disposable = observable - .Finally(CheckCompleted) - .SubscribeSafe( - onNext: val => ChildOnNext(val, parentKey), - onError: TerminalError, - onCompleted: () => RemoveChildSubscription(parentKey)); - } - - protected void RemoveChildSubscription(TKey parentKey) => _childSubscriptions.Remove(parentKey); - - protected void CreateParentSubscription(IObservable> source) => - _parentSubscription.Disposable = - source - .SynchronizeSafe(_queue) - .SubscribeSafe( - onNext: ParentOnNext, - onError: TerminalError, - onCompleted: CheckCompleted); - - protected virtual void Dispose(bool disposing) - { - if (!_disposedValue) - { - if (disposing) - { - _queue.Dispose(); - _parentSubscription.Dispose(); - _childSubscriptions.Dispose(); - } - - _disposedValue = true; - } - } - - /// - /// Wraps a child observable through the shared delivery queue for serialization. - /// Must be called by derived classes on observables passed to . - /// Same-thread reentrant delivery ensures child items are delivered inline during - /// parent processing, preserving the original Synchronize(lock) ordering semantics. - /// - protected IObservable MakeChildObservable(IObservable observable) => - observable.SynchronizeSafe(_queue); - - private void OnDrainComplete() - { - EmitChanges(_observer); - - if (Volatile.Read(ref _isCompleted) && !_hasTerminated) - { - _hasTerminated = true; - _observer.OnCompleted(); - } - } - - private void TerminalError(Exception error) - { - _hasTerminated = true; - _observer.OnError(error); - } - - private void CheckCompleted() - { - if (Interlocked.Decrement(ref _subscriptionCounter) == 0) - { - Volatile.Write(ref _isCompleted, true); - } - - Debug.Assert(_subscriptionCounter >= 0, "Should never be negative"); - } -} diff --git a/src/DynamicData/Internal/KeyedDisposable.cs b/src/DynamicData/Internal/KeyedDisposable.cs index 4e2ab2951..de72dca69 100644 --- a/src/DynamicData/Internal/KeyedDisposable.cs +++ b/src/DynamicData/Internal/KeyedDisposable.cs @@ -58,10 +58,12 @@ public bool IsDisposed } /// - /// Tracks an item by key. If the item implements , - /// it replaces any existing entry (disposing the previous one if different). - /// If the item is NOT disposable, any existing entry for the key is removed - /// and disposed. + /// Tracks an item by key. If the item implements , it is registered + /// against : any previously registered disposable for the same key is + /// disposed and replaced (unless it is reference-equal to the new one, in which case nothing + /// changes). If the item is NOT disposable, any existing entry for the key is removed and + /// disposed; no new entry is registered. Despite the name "Add", this method has replace-or-add + /// semantics on key collision. /// public TItem Add(TKey key, TItem item) where TItem : notnull diff --git a/src/DynamicData/Internal/SharedDeliveryQueue.cs b/src/DynamicData/Internal/SharedDeliveryQueue.cs index 6eab28894..e45a4735f 100644 --- a/src/DynamicData/Internal/SharedDeliveryQueue.cs +++ b/src/DynamicData/Internal/SharedDeliveryQueue.cs @@ -16,7 +16,7 @@ namespace DynamicData.Internal; internal sealed class SharedDeliveryQueue : IDisposable { private readonly List _sources = []; - private readonly Action? _onDrainComplete; + private readonly Action? _onDrainComplete; #if NET9_0_OR_GREATER private readonly Lock _gate; @@ -27,6 +27,7 @@ internal sealed class SharedDeliveryQueue : IDisposable private Bitset _activeBits = new(); private int _deadCount; private int _drainThreadId = -1; + private bool _drainReentered; private volatile bool _isTerminated; /// Initializes a new instance of the class with its own internal lock. @@ -37,9 +38,11 @@ public SharedDeliveryQueue() /// /// Initializes a new instance of the class with its own internal lock - /// and a callback that fires outside the lock after each drain cycle completes. + /// and a callback that fires outside the lock after each drain cycle completes. The callback receives + /// a indicating whether a reentrant drain (same-thread re-entry via + /// ) occurred during the prior delivery cycle. /// - public SharedDeliveryQueue(Action? onDrainComplete) + public SharedDeliveryQueue(Action? onDrainComplete) { #if NET9_0_OR_GREATER _gate = new Lock(); @@ -157,6 +160,8 @@ internal void ExitLockAndDrain() if (_drainThreadId == currentThreadId) { ExitLock(); + // Flag so the outer DrainAll re-fires _onDrainComplete after this reentrant drain. + _drainReentered = true; DrainPending(); return; } @@ -180,6 +185,9 @@ private void DrainAll() { try { + // Reentrancy flag for the prior delivery cycle, reset and re-captured each iteration. + var wasReentrant = false; + while (true) { if (!DrainPending()) @@ -198,21 +206,21 @@ private void DrainAll() return; } + // Snapshot before the callback so the next iteration's flag reflects only what + // happened during _onDrainComplete itself. + wasReentrant = _drainReentered; + _drainReentered = false; + if (_onDrainComplete is not null) { - _onDrainComplete(); + _onDrainComplete(wasReentrant); } - // Atomically check for pending items and release drain ownership - // if empty. This closes the TOCTOU window: if we checked and released - // in separate lock scopes, Thread B could enqueue between them, - // see _drainThreadId != -1, and rely on us to drain, but we'd exit - // without draining Thread B's item. + // Loop back if items are pending OR a reentrant drain ran during _onDrainComplete. EnterLock(); - if (_activeBits.HasAny() && !_isTerminated) + if ((_activeBits.HasAny() || _drainReentered) && !_isTerminated) { - // Items arrived during _onDrainComplete. Loop back to drain them. ExitLock(); continue; } diff --git a/src/DynamicData/List/Internal/ClonedListChangeSet.cs b/src/DynamicData/List/Internal/ClonedListChangeSet.cs index 3b9996a1f..4e321bfb3 100644 --- a/src/DynamicData/List/Internal/ClonedListChangeSet.cs +++ b/src/DynamicData/List/Internal/ClonedListChangeSet.cs @@ -2,17 +2,18 @@ // 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.Linq; - namespace DynamicData.List.Internal; -internal sealed class ClonedListChangeSet +/// +/// Holds an observable list changeset alongside an aggregated mirror list. +/// applies a changeset to . +/// +internal sealed class ClonedListChangeSet(IObservable> source, IEqualityComparer? equalityComparer) where TObject : notnull { - public ClonedListChangeSet(IObservable> source, IEqualityComparer? equalityComparer) => - Source = source.Do(changeSet => List.Clone(changeSet, equalityComparer)); - public List List { get; } = []; - public IObservable> Source { get; } + public IObservable> Source { get; } = source; + + public void Process(IChangeSet changes) => List.Clone(changes, equalityComparer); } diff --git a/src/DynamicData/List/Internal/MergeChangeSets.cs b/src/DynamicData/List/Internal/MergeChangeSets.cs index 480c69b3a..b6aea7381 100644 --- a/src/DynamicData/List/Internal/MergeChangeSets.cs +++ b/src/DynamicData/List/Internal/MergeChangeSets.cs @@ -29,7 +29,7 @@ public IObservable> Run() => Observable.Create clonedList.Source.RemoveIndex().Do(static _ => { }, observer.OnError)) + .MergeMany(clonedList => clonedList.Source.Do(clonedList.Process).RemoveIndex()) .Subscribe( changes => changeTracker.ProcessChangeSet(changes, observer), observer.OnError, diff --git a/src/DynamicData/List/Internal/MergeMany.cs b/src/DynamicData/List/Internal/MergeMany.cs index 65b238068..40cb63e23 100644 --- a/src/DynamicData/List/Internal/MergeMany.cs +++ b/src/DynamicData/List/Internal/MergeMany.cs @@ -24,7 +24,7 @@ public IObservable Run() => Observable.Create( .SubscribeMany(t => { counter.Added(); - return _observableSelector(t).Synchronize(locker).Finally(() => counter.Finally()).Subscribe(observer.OnNext, _ => { }, () => { }); + return _observableSelector(t).Synchronize(locker).Finally(() => counter.Finally()).Subscribe(observer.OnNext, observer.OnError, () => { }); }) .Subscribe(_ => { }, observer.OnError, observer.OnCompleted); diff --git a/src/DynamicData/List/Internal/MergeManyCacheChangeSets.cs b/src/DynamicData/List/Internal/MergeManyCacheChangeSets.cs index 3b63ea231..a7be674be 100644 --- a/src/DynamicData/List/Internal/MergeManyCacheChangeSets.cs +++ b/src/DynamicData/List/Internal/MergeManyCacheChangeSets.cs @@ -37,7 +37,7 @@ public IObservable> Run() => Observabl // Merge the child changeset changes together and apply to the tracker var subMergeMany = shared - .MergeMany(chanceSetCache => chanceSetCache.Source) + .MergeMany(chanceSetCache => chanceSetCache.Source.Do(chanceSetCache.Process)) .SubscribeSafe( changes => changeTracker.ProcessChangeSet(changes, !parentUpdate ? observer : null), observer.OnError, diff --git a/src/DynamicData/List/Internal/MergeManyListChangeSets.cs b/src/DynamicData/List/Internal/MergeManyListChangeSets.cs index 482c4f46b..edab5c1f3 100644 --- a/src/DynamicData/List/Internal/MergeManyListChangeSets.cs +++ b/src/DynamicData/List/Internal/MergeManyListChangeSets.cs @@ -33,7 +33,7 @@ public IObservable> Run() => Observable.Create clonedList.Source.RemoveIndex()) + .MergeMany(clonedList => clonedList.Source.Do(clonedList.Process).RemoveIndex()) .SubscribeSafe( changes => changeTracker.ProcessChangeSet(changes, !parentUpdate ? observer : null), observer.OnError, diff --git a/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs b/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs index dc3ede92c..1c108c2eb 100644 --- a/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs +++ b/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs @@ -47,6 +47,7 @@ public static partial class ObservableListEx /// Remove/RemoveRange/ClearUnsubscribes from removed items. The original change is forwarded. /// Moved/RefreshForwarded unchanged. /// Re-evaluator firesThe item's current index is looked up and a Refresh change is emitted. + /// OnErrorErrors from any per-item re-evaluator terminate the output. Source errors also terminate the output. /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs b/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs index bd49bf1db..5dc6f3801 100644 --- a/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs +++ b/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs @@ -50,6 +50,7 @@ public static partial class ObservableListEx /// Event (per-item observable)Behavior /// Emits If not already included, an Add is emitted downstream. /// Emits If currently included, a Remove is emitted downstream. + /// OnErrorErrors from any per-item filter observable terminate the output. Source errors also terminate the output. /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.MergeMany.cs b/src/DynamicData/List/ObservableListEx.MergeMany.cs index 55dfa4f19..73de2fd82 100644 --- a/src/DynamicData/List/ObservableListEx.MergeMany.cs +++ b/src/DynamicData/List/ObservableListEx.MergeMany.cs @@ -41,6 +41,7 @@ public static partial class ObservableListEx /// Remove/RemoveRange/ClearSubscription disposed. /// Refresh/MovedNo effect on subscriptions. /// OnCompleted (source)Completes only after the source and all active inner observables have completed. + /// OnErrorErrors from any per-item observable terminate the merged output. Source errors also terminate the output. /// /// ///