From 7d966752a1e677345d1231de6bcb105c3a201305 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Fri, 29 May 2026 12:23:55 -0700 Subject: [PATCH 01/30] Drop of new internal operator --- .../Internal/AggregateManyExtensions.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/DynamicData/Internal/AggregateManyExtensions.cs diff --git a/src/DynamicData/Internal/AggregateManyExtensions.cs b/src/DynamicData/Internal/AggregateManyExtensions.cs new file mode 100644 index 00000000..09c4081a --- /dev/null +++ b/src/DynamicData/Internal/AggregateManyExtensions.cs @@ -0,0 +1,87 @@ +// 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.Internal; + +/// +/// Provides the operator, a delegate-driven entry point to the +/// batching machinery that manages per-key +/// child subscriptions and coalesces parent and child notifications into a single downstream emission per drain cycle. +/// +internal static class AggregateManyExtensions +{ + /// + /// Subscribes to a parent changeset and manages per-key child subscriptions, aggregating parent + /// and child notifications into a single downstream emission per drain cycle via . + /// + /// Type of the parent changeset items. + /// Type of the parent changeset key. + /// Type of the per-key child observable values. + /// Type of the downstream observer notifications. + /// The parent changeset source. + /// + /// Receives each parent changeset and a setChild callback that adds, replaces, + /// or removes the child subscription for a key. Pass a non- observable + /// to add or replace; pass to remove. The callback automatically + /// routes the child observable through the shared delivery queue so callers do not + /// need to synchronize it themselves. + /// + /// Receives each value emitted by a child observable, paired with its parent key. + /// Invoked once per drain cycle to flush accumulated state to the observer. + /// The aggregated observable stream. + public static IObservable AggregateMany( + this IObservable> source, + Action, Action?>> onParent, + Action onChild, + Action> tryEmit) + where TParent : notnull + where TKey : notnull + where TChild : notnull => + Observable.Create(observer => new DelegatedAggregator(source, observer, onParent, onChild, tryEmit)); + + private sealed class DelegatedAggregator + : CacheParentSubscription + where TParent : notnull + where TKey : notnull + where TChild : notnull + { + private readonly Action, Action?>> _onParent; + private readonly Action _onChild; + private readonly Action> _tryEmit; + + public DelegatedAggregator( + IObservable> source, + IObserver observer, + Action, Action?>> onParent, + Action onChild, + Action> tryEmit) + : base(observer) + { + _onParent = onParent; + _onChild = onChild; + _tryEmit = tryEmit; + CreateParentSubscription(source); + } + + protected override void ParentOnNext(IChangeSet changes) => _onParent(changes, SetChild); + + protected override void ChildOnNext(TChild child, TKey parentKey) => _onChild(child, parentKey); + + protected override void EmitChanges(IObserver observer) => _tryEmit(observer); + + private void SetChild(TKey key, IObservable? observable) + { + if (observable is null) + { + RemoveChildSubscription(key); + } + else + { + AddChildSubscription(MakeChildObservable(observable), key); + } + } + } +} From a3d5717ff73562627ee75f93ad281ab58e5dd0ab Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Fri, 29 May 2026 16:20:08 -0700 Subject: [PATCH 02/30] Refactor FilterOnObservable and add new test Added a new test `FilterObservableError_PropagatesDownstream` to ensure error propagation in `FilterOnObservableFixture`. Introduced `AggregateManyExtensions` class to provide the `AggregateMany` operator for aggregating changesets. Refactored `FilterOnObservable` to utilize the new `AggregateMany` operator, removing the `FilterProxy` implementation. Adjusted copyright header for consistency. Replaced the old `AggregateManyExtensions` with a new implementation. --- .../Cache/FilterOnObservableFixture.cs | 21 +++ .../Cache/Internal/FilterOnObservable.cs | 71 ++++++-- .../Internal/AggregateManyExtensions.cs | 167 ++++++++++++------ 3 files changed, 192 insertions(+), 67 deletions(-) diff --git a/src/DynamicData.Tests/Cache/FilterOnObservableFixture.cs b/src/DynamicData.Tests/Cache/FilterOnObservableFixture.cs index 84b48705..a6b3a422 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/Cache/Internal/FilterOnObservable.cs b/src/DynamicData/Cache/Internal/FilterOnObservable.cs index d444d9ac..4d2862fd 100644 --- a/src/DynamicData/Cache/Internal/FilterOnObservable.cs +++ b/src/DynamicData/Cache/Internal/FilterOnObservable.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. @@ -11,22 +11,63 @@ 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() => Observable.Create>(observer => + { + var cache = new ChangeAwareCache(); - 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); + var changes = source.AggregateMany>( + onSource: (parentChanges, track) => + { + foreach (var change in parentChanges.ToConcreteType()) + { + switch (change.Reason) + { + // Drop any cached value upfront. Synchronous emissions from the new inner observable + // collapse with this Remove inside the same captured changeset. + case ChangeReason.Add or ChangeReason.Update: + cache.Remove(change.Key); + var item = change.Current; + track(change.Key, filterFactory(item, change.Key).DistinctUntilChanged().Select(passes => (item, passes))); + break; - private sealed class FilterProxy(TObject obj, IObservable observable) - { - public TObject Value { get; } = obj; + case ChangeReason.Remove: + cache.Remove(change.Key); + track(change.Key, null); + break; + + case ChangeReason.Refresh: + cache.Refresh(change.Key); + break; + } + } + }, + onInner: (value, key) => + { + if (value.Passes) + { + cache.AddOrUpdate(value.Item, key); + } + else + { + cache.Remove(key); + } + }, + emit: o => + { + var captured = cache.CaptureChanges(); + if (captured.Count > 0) + { + o.OnNext(captured); + } + }); - public bool PassesFilter { get; private set; } + if (buffer is { } window) + { + changes = changes.Buffer(window, scheduler ?? GlobalConfig.DefaultScheduler) + .Where(static batches => batches.Count > 0) + .Select(static batches => new ChangeSet(batches.SelectMany(static cs => cs))); + } - public IObservable FilterObservable => observable.DistinctUntilChanged().Do(filterValue => PassesFilter = filterValue); - } + return changes.SubscribeSafe(observer); + }); } diff --git a/src/DynamicData/Internal/AggregateManyExtensions.cs b/src/DynamicData/Internal/AggregateManyExtensions.cs index 09c4081a..963b6205 100644 --- a/src/DynamicData/Internal/AggregateManyExtensions.cs +++ b/src/DynamicData/Internal/AggregateManyExtensions.cs @@ -2,86 +2,149 @@ // 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; /// -/// Provides the operator, a delegate-driven entry point to the -/// batching machinery that manages per-key -/// child subscriptions and coalesces parent and child notifications into a single downstream emission per drain cycle. +/// Provides the operator: a delegate-driven entry point +/// that subscribes to a keyed source changeset, manages per-key inner subscriptions, and coalesces source and inner +/// notifications into a single downstream emission per drain cycle of an internal . /// internal static class AggregateManyExtensions { /// - /// Subscribes to a parent changeset and manages per-key child subscriptions, aggregating parent - /// and child notifications into a single downstream emission per drain cycle via . + /// Aggregates a keyed source changeset and a dynamic set of per-key inner observables into a single result stream. /// - /// Type of the parent changeset items. - /// Type of the parent changeset key. - /// Type of the per-key child observable values. - /// Type of the downstream observer notifications. - /// The parent changeset source. - /// - /// Receives each parent changeset and a setChild callback that adds, replaces, - /// or removes the child subscription for a key. Pass a non- observable - /// to add or replace; pass to remove. The callback automatically - /// routes the child observable through the shared delivery queue so callers do not - /// need to synchronize it 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 by . + /// The keyed source changeset stream. + /// + /// Invoked for each source changeset, paired with a track callback that registers, + /// replaces, or removes the inner observable for a key. Pass a non- + /// observable to register or replace; pass to remove. The callback + /// routes the inner observable through the shared delivery queue so callers do not need + /// to synchronize it themselves. /// - /// Receives each value emitted by a child observable, paired with its parent key. - /// Invoked once per drain cycle to flush accumulated state to the observer. - /// The aggregated observable stream. - public static IObservable AggregateMany( - this IObservable> source, - Action, Action?>> onParent, - Action onChild, - Action> tryEmit) - where TParent : notnull + /// Invoked for each value emitted by a tracked inner observable, paired with its key. + /// Invoked once per drain cycle to flush the aggregated state to the observer. + /// An observable that aggregates source and inner activity into a single result stream. + public static IObservable AggregateMany( + this IObservable> source, + Action, Action?>> onSource, + Action onInner, + Action> emit) + where TSource : notnull where TKey : notnull - where TChild : notnull => - Observable.Create(observer => new DelegatedAggregator(source, observer, onParent, onChild, tryEmit)); + where TInner : notnull => + Observable.Create(observer => new Subscription(source, observer, onSource, onInner, emit)); - private sealed class DelegatedAggregator - : CacheParentSubscription - where TParent : notnull + private sealed class Subscription : IDisposable + where TSource : notnull where TKey : notnull - where TChild : notnull + where TInner : notnull { - private readonly Action, Action?>> _onParent; - private readonly Action _onChild; - private readonly Action> _tryEmit; + private readonly KeyedDisposable _innerSubscriptions = new(); + private readonly SingleAssignmentDisposable _sourceSubscription = new(); + private readonly SharedDeliveryQueue _queue; + private readonly IObserver _observer; + private readonly Action, Action?>> _onSource; + private readonly Action _onInner; + private readonly Action> _emit; + private int _subscriptionCounter = 1; + private bool _isCompleted; + private bool _hasTerminated; + private bool _disposed; - public DelegatedAggregator( - IObservable> source, + public Subscription( + IObservable> source, IObserver observer, - Action, Action?>> onParent, - Action onChild, - Action> tryEmit) - : base(observer) + Action, Action?>> onSource, + Action onInner, + Action> emit) { - _onParent = onParent; - _onChild = onChild; - _tryEmit = tryEmit; - CreateParentSubscription(source); - } + _observer = observer; + _onSource = onSource; + _onInner = onInner; + _emit = emit; + _queue = new SharedDeliveryQueue(onDrainComplete: OnDrainComplete); - protected override void ParentOnNext(IChangeSet changes) => _onParent(changes, SetChild); + _sourceSubscription.Disposable = source + .SynchronizeSafe(_queue) + .SubscribeSafe( + onNext: changes => _onSource(changes, Track), + onError: TerminalError, + onCompleted: DecrementSubscriptionCount); + } - protected override void ChildOnNext(TChild child, TKey parentKey) => _onChild(child, parentKey); + public void Dispose() + { + if (_disposed) + { + return; + } - protected override void EmitChanges(IObserver observer) => _tryEmit(observer); + _disposed = true; + _queue.Dispose(); + _sourceSubscription.Dispose(); + _innerSubscriptions.Dispose(); + } - private void SetChild(TKey key, IObservable? observable) + private void Track(TKey key, IObservable? observable) { if (observable is null) { - RemoveChildSubscription(key); + _innerSubscriptions.Remove(key); + return; + } + + // Increment before adding so the OnCompleted callback that fires when the previous subscription + // for this key is disposed does not race the counter down to zero and signal premature termination. + Interlocked.Increment(ref _subscriptionCounter); + + var container = _innerSubscriptions.Add(key, new SingleAssignmentDisposable()); + + // Finally(DecrementSubscriptionCount) fires on completion, error, AND disposal, so the counter + // always decrements. The onCompleted callback only fires on normal completion, so an inner + // 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: TerminalError, + onCompleted: () => _innerSubscriptions.Remove(key)); + } + + private void OnDrainComplete() + { + _emit(_observer); + + if (Volatile.Read(ref _isCompleted) && !_hasTerminated) + { + _hasTerminated = true; + _observer.OnCompleted(); } - else + } + + private void TerminalError(Exception error) + { + _hasTerminated = true; + _observer.OnError(error); + } + + private void DecrementSubscriptionCount() + { + if (Interlocked.Decrement(ref _subscriptionCounter) == 0) { - AddChildSubscription(MakeChildObservable(observable), key); + Volatile.Write(ref _isCompleted, true); } + + Debug.Assert(_subscriptionCounter >= 0, "Should never be negative"); } } } From 51eaec69644d28c0db2b941388d6d7f26a6da952 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Fri, 29 May 2026 17:09:26 -0700 Subject: [PATCH 03/30] Refactor Observable Classes for Clarity Renamed `onSource` to `onSourceChangeSet` in `FilterOnObservable` and `AggregateManyExtensions` for better clarity. Refactored `GroupOnObservable` and `TransformOnObservable` to use `Observable.Using` and `Observable.Defer` with `AggregateMany`, removing the `Subscription` class to simplify code and improve efficiency. --- .../Cache/Internal/FilterOnObservable.cs | 2 +- .../Cache/Internal/GroupOnObservable.cs | 78 +++++--------- .../Cache/Internal/TransformOnObservable.cs | 102 +++++++----------- .../Internal/AggregateManyExtensions.cs | 6 +- 4 files changed, 69 insertions(+), 119 deletions(-) diff --git a/src/DynamicData/Cache/Internal/FilterOnObservable.cs b/src/DynamicData/Cache/Internal/FilterOnObservable.cs index 4d2862fd..6ede12b2 100644 --- a/src/DynamicData/Cache/Internal/FilterOnObservable.cs +++ b/src/DynamicData/Cache/Internal/FilterOnObservable.cs @@ -16,7 +16,7 @@ public IObservable> Run() => Observable.Create(); var changes = source.AggregateMany>( - onSource: (parentChanges, track) => + onSourceChangeSet: (parentChanges, track) => { foreach (var change in parentChanges.ToConcreteType()) { diff --git a/src/DynamicData/Cache/Internal/GroupOnObservable.cs b/src/DynamicData/Cache/Internal/GroupOnObservable.cs index 0555004e..97533d85 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.AggregateMany>( + onSourceChangeSet: (changes, track) => { - // 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; + track(key, selectGroup(item, key).DistinctUntilChanged().Select(groupKey => (groupKey, item))); + break; + + case ChangeReason.Remove: + track(change.Key, null); + break; + } + } + }, + onInner: (value, parentKey) => grouper.AddOrUpdate(parentKey, value.GroupKey, value.Item), + emit: observer => grouper.EmitChanges(observer))); } diff --git a/src/DynamicData/Cache/Internal/TransformOnObservable.cs b/src/DynamicData/Cache/Internal/TransformOnObservable.cs index b88e4f1c..35508e5a 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.AggregateMany>( + onSourceChangeSet: (changes, track) => { - 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: + track(change.Key, transform(change.Current, change.Key).DistinctUntilChanged()); + break; + + case ChangeReason.Remove: + cache.Remove(change.Key); + track(change.Key, null); + break; + + case ChangeReason.Refresh: + if (transformOnRefresh) + { + 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), + emit: 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/Internal/AggregateManyExtensions.cs b/src/DynamicData/Internal/AggregateManyExtensions.cs index 963b6205..a810baaa 100644 --- a/src/DynamicData/Internal/AggregateManyExtensions.cs +++ b/src/DynamicData/Internal/AggregateManyExtensions.cs @@ -23,7 +23,7 @@ internal static class AggregateManyExtensions /// Type of values emitted by the per-key inner observables. /// Type delivered downstream by . /// The keyed source changeset stream. - /// + /// /// Invoked for each source changeset, paired with a track callback that registers, /// replaces, or removes the inner observable for a key. Pass a non- /// observable to register or replace; pass to remove. The callback @@ -35,13 +35,13 @@ internal static class AggregateManyExtensions /// An observable that aggregates source and inner activity into a single result stream. public static IObservable AggregateMany( this IObservable> source, - Action, Action?>> onSource, + Action, Action?>> onSourceChangeSet, Action onInner, Action> emit) where TSource : notnull where TKey : notnull where TInner : notnull => - Observable.Create(observer => new Subscription(source, observer, onSource, onInner, emit)); + Observable.Create(observer => new Subscription(source, observer, onSourceChangeSet, onInner, emit)); private sealed class Subscription : IDisposable where TSource : notnull From 524baf7dc7cf9834a8de0d5a4d1da609e08d6e88 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 1 Jun 2026 07:35:07 -0700 Subject: [PATCH 04/30] Refactor cache operators onto Orchestrator composition primitive Replaces inheritance-based CacheParentSubscription with composition-based ICacheOrchestrator + Orchestrator runner. Per-subscription state is owned by orchestrator instances; runtime context (Track/Serialize/ScheduleEmit) is injected via Initialize on a fresh Subscription created inside each Observable.Create call, so multi-subscribe correctness is preserved. Primitive - ICacheOrchestrator / ICacheOrchestratorContext (Cache.Internal) - Orchestrator.Run() wraps Observable.Create - IntObservableCacheEx.OrchestrateMany family of extension methods plus AsCacheOrchestrator lambda adapter - Specialized helpers OrchestrateManyChanges (ChangeAwareCache mirror), OrchestrateManyMerged (cache->cache merge), and OrchestrateManyMergedList (cache->list merge) Migrated operators - FilterOnObservable, TransformOnObservable, GroupOnObservable - MergeManyCacheChangeSets, MergeManyCacheChangeSetsSourceCompare, MergeManyListChangeSets (Cache/Internal/) - TransformManyAsync (showcase for Serialize hook) - AutoRefresh (uses ScheduleEmit for shared-grid buffered refresh batching; source pass-through preserved verbatim; refreshes suppressed for keys the source has touched in the same drain, fixes #1099) Behavior changes (for next major version) - AutoRefresh and AutoRefreshOnObservable now propagate reevaluator exceptions (sync throw and OnError) instead of silently swallowing them - AutoRefresh now throws ArgumentNullException for null propertyAccessor / reevaluator at composition time instead of throwing NRE on first event - FilterOnObservable with non-null buffer now uses quiescence-based Pub+Buf+Throttle+Amb instead of fixed-grid Buffer; same coalescing, zero idle allocations - AutoRefresh no longer emits Refresh changes for keys touched by source in the same drain (eliminates redundant Add+Refresh and prevents Add+Remove+Refresh) Other fixes - Removed over-strict debug assertion in SuspendNotifications (functional state unaffected, only DEBUG-mode race window flagged) - Deterministic seed in TransformAsyncFixture.RemoveFlowsToTheEnd (replaces Random.Shared) - Cross-cache deadlock fixture ported to OrchestrateManyFixture Deletes - CacheParentSubscription.cs and CacheParentSubscriptionFixture.cs --- ...AutoRefreshFixture.WithPropertyAccessor.cs | 2 +- .../AutoRefreshOnObservableFixture.Base.cs | 6 +- ...toRefreshOnObservableFixture.WithoutKey.cs | 2 +- .../Cache/SizeLimitFixture.cs | 2 +- .../Cache/TransformAsyncFixture.cs | 9 +- ...onFixture.cs => OrchestrateManyFixture.cs} | 114 +++++++------- src/DynamicData/Cache/Internal/AutoRefresh.cs | 112 +++++++++++--- .../Cache/Internal/FilterOnObservable.cs | 72 ++++----- .../Cache/Internal/GroupOnObservable.cs | 2 +- .../Cache/Internal/ICacheOrchestrator.cs | 48 ++++++ .../Internal/ICacheOrchestratorContext.cs | 52 +++++++ .../IntObservableCacheEx.OrchestrateMany.cs | 100 ++++++++++++ ...bservableCacheEx.OrchestrateManyChanges.cs | 95 ++++++++++++ ...ObservableCacheEx.OrchestrateManyMerged.cs | 110 +++++++++++++ ...rvableCacheEx.OrchestrateManyMergedList.cs | 92 +++++++++++ .../Internal/MergeManyCacheChangeSets.cs | 69 +-------- .../MergeManyCacheChangeSetsSourceCompare.cs | 83 ++-------- .../Cache/Internal/MergeManyListChangeSets.cs | 63 +------- .../Internal/Orchestrator.cs} | 134 +++++++++------- .../Cache/Internal/TransformManyAsync.cs | 113 +++++++------- .../Cache/Internal/TransformOnObservable.cs | 2 +- src/DynamicData/Cache/ObservableCache.cs | 1 - src/DynamicData/Cache/ObservableCacheEx.cs | 9 +- .../Internal/CacheParentSubscription.cs | 145 ------------------ 24 files changed, 859 insertions(+), 578 deletions(-) rename src/DynamicData.Tests/Internal/{CacheParentSubscriptionFixture.cs => OrchestrateManyFixture.cs} (72%) create mode 100644 src/DynamicData/Cache/Internal/ICacheOrchestrator.cs create mode 100644 src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs create mode 100644 src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs create mode 100644 src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs create mode 100644 src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs create mode 100644 src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs rename src/DynamicData/{Internal/AggregateManyExtensions.cs => Cache/Internal/Orchestrator.cs} (50%) delete mode 100644 src/DynamicData/Internal/CacheParentSubscription.cs diff --git a/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs b/src/DynamicData.Tests/Cache/AutoRefreshFixture.WithPropertyAccessor.cs index c948aedb..80c75151 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 a09e0ea9..c3b1fd1d 100644 --- a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs @@ -495,7 +495,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 +523,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 +564,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 3e96d6e9..7e0136a4 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/SizeLimitFixture.cs b/src/DynamicData.Tests/Cache/SizeLimitFixture.cs index 378b7fa6..f20b590b 100644 --- a/src/DynamicData.Tests/Cache/SizeLimitFixture.cs +++ b/src/DynamicData.Tests/Cache/SizeLimitFixture.cs @@ -125,7 +125,7 @@ public void InvokeLimitSizeToWhenOverLimit() subscriber.Dispose(); } - [Fact(Skip = "Need to re-examine and fix failure")] + [Fact] public void OnCompleteIsInvokedWhenSourceIsDisposed() { var completed = false; diff --git a/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs b/src/DynamicData.Tests/Cache/TransformAsyncFixture.cs index 7483eccb..6599159c 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/CacheParentSubscriptionFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs similarity index 72% rename from src/DynamicData.Tests/Internal/CacheParentSubscriptionFixture.cs rename to src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs index 5f11b9fc..b5fa6d96 100644 --- a/src/DynamicData.Tests/Internal/CacheParentSubscriptionFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.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. @@ -11,7 +11,7 @@ using Bogus; -using DynamicData.Internal; +using DynamicData.Cache.Internal; using DynamicData.Tests.Utilities; using FluentAssertions; @@ -21,10 +21,12 @@ namespace DynamicData.Tests.Internal; /// -/// Tests for -/// behavioral contracts using a minimal concrete subclass. +/// Tests for the OrchestrateMany primitive's behavioral contracts: source/inner serialization, +/// per-drain coalesced emission, completion counting, error propagation, and cross-cache safety. +/// Exercised via the overload because it +/// maps 1:1 to the legacy CacheParentSubscription subclass shape these tests originally targeted. /// -public sealed class CacheParentSubscriptionFixture +public sealed class OrchestrateManyFixture { private const int SeedMin = 1; private const int SeedMax = 10000; @@ -33,7 +35,7 @@ public sealed class CacheParentSubscriptionFixture private readonly Randomizer _rand = new(55); - /// Test item with a typed key — no string parsing. + /// Test item with a typed key. private sealed record TestItem(int Key, string Value); [Fact] @@ -42,8 +44,8 @@ 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 orchestrator = new TestOrchestrator(); + using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); var items = Enumerable.Range(0, itemCount) .Select(i => new TestItem(_rand.Number(SeedMin, SeedMax) + i * 100, _rand.String2(_rand.Number(3, 10)))) @@ -52,8 +54,8 @@ public void ParentOnNext_CalledForEachChangeSet() 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"); + orchestrator.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] @@ -62,13 +64,13 @@ 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 orchestrator = new TestOrchestrator(key => { var subj = new Subject(); childSubjects.Add(subj); return subj; }); - sub.ExposeCreateParent(source.Connect()); + using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); var key = _rand.Number(SeedMin, SeedMax); source.AddOrUpdate(new TestItem(key, "parent")); @@ -77,7 +79,7 @@ public void ChildOnNext_CalledForEachEmission() var childValue = _rand.String2(_rand.Number(5, 15)); childSubjects[0].OnNext(childValue); - sub.ChildCalls.Should().ContainSingle() + orchestrator.ChildCalls.Should().ContainSingle() .Which.Should().Be((childValue, key)); } @@ -87,8 +89,8 @@ 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()); + var orchestrator = new TestOrchestrator(); + using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); source.Edit(updater => { @@ -96,8 +98,8 @@ public void EmitChanges_FiresOnceForBatch() 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"); + orchestrator.ParentCallCount.Should().Be(1, "single batch = single OnSourceChangeSet"); + orchestrator.EmitCallCount.Should().Be(1, "single batch = single Emit"); } [Fact] @@ -107,12 +109,12 @@ public void Batching_ChildUpdatesSettleBeforeEmit() using var source = new SourceCache(x => x.Key); var observer = new TestObserver(); var childCount = 0; - using var sub = new TestSubscription(observer, key => + var orchestrator = new TestOrchestrator(key => { Interlocked.Increment(ref childCount); return new BehaviorSubject($"sync-{key}"); }); - sub.ExposeCreateParent(source.Connect()); + using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); source.Edit(updater => { @@ -121,8 +123,8 @@ public void Batching_ChildUpdatesSettleBeforeEmit() }); childCount.Should().Be(batchSize, "each item should create a child"); - sub.EmitCallCount.Should().BeGreaterThanOrEqualTo(1, - "EmitChanges fires after parent + children settle"); + orchestrator.EmitCallCount.Should().BeGreaterThanOrEqualTo(1, + "Emit fires after parent + children settle"); } [Fact] @@ -131,13 +133,13 @@ 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 orchestrator = new TestOrchestrator(key => { var subj = new Subject(); childSubjects.Add(subj); return subj; }); - sub.ExposeCreateParent(source.Connect()); + using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); childSubjects.Should().HaveCount(1); @@ -154,8 +156,8 @@ 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()); + var orchestrator = new TestOrchestrator(); + using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); source.Complete(); observer.IsCompleted.Should().BeTrue("immediate OnCompleted when no children"); @@ -167,13 +169,13 @@ 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 orchestrator = new TestOrchestrator(key => { var subj = new Subject(); childSubjects.Add(subj); return subj; }); - sub.ExposeCreateParent(source.Connect()); + var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); var emitsBefore = observer.EmitCount; @@ -192,8 +194,8 @@ 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 orchestrator = new TestOrchestrator(); + using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); var error = new InvalidOperationException("test error"); source.SetError(error); @@ -207,16 +209,11 @@ 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; - }, + var orchestrator = new TestOrchestrator( + key => new Subject(), 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()); + using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); @@ -229,9 +226,10 @@ public void Serialization_ParentAndChildDoNotInterleave() } /// - /// 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. + /// Proves OrchestrateMany delivery runs without holding the lock. Two orchestrator instances + /// whose Emit callbacks write into each other's source cache, creating a cross-cache cycle. + /// Deadlocks if downstream delivery is held under the queue lock; passes when the queue is + /// drained before invoking Emit. /// [Trait("Category", "ExplicitDeadlock")] [Fact] @@ -242,14 +240,13 @@ public async Task DeadlockProof_CrossFeedingSubscriptions() 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 orchestratorA = new TestOrchestrator(); + using var subA = sourceA.Connect().OrchestrateMany(orchestratorA).Subscribe(observerA); var observerB = new CrossFeedObserver(sourceA, 200_001, iterations); - using var subB = new TestSubscription(observerB); - subB.ExposeCreateParent(sourceB.Connect()); + var orchestratorB = new TestOrchestrator(); + using var subB = sourceB.Connect().OrchestrateMany(orchestratorB).Subscribe(observerB); using var barrier = new Barrier(2); @@ -272,7 +269,7 @@ public async Task DeadlockProof_CrossFeedingSubscriptions() 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"); + "cross-feeding OrchestrateMany subscriptions should not deadlock"); } // ═══════════════════════════════════════════════════════════════ @@ -286,7 +283,6 @@ private sealed class CrossFeedObserver(SourceCache target, int id 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")); @@ -299,9 +295,9 @@ public void OnCompleted() { } } /// - /// Minimal concrete CacheParentSubscription for testing. + /// Minimal ICacheOrchestrator implementation that mirrors the legacy CPS-subclass shape. /// - private sealed class TestSubscription : CacheParentSubscription> + private sealed class TestOrchestrator : ICacheOrchestrator> { private readonly Func>? _childFactory; private readonly Action? _onParent; @@ -311,23 +307,21 @@ private sealed class TestSubscription : CacheParentSubscription ChildCalls = []; + private ICacheOrchestratorContext _context = null!; - public TestSubscription( - IObserver> observer, + public TestOrchestrator( Func>? childFactory = null, Action? onParent = null, Action? onChild = null) - : base(observer) { _childFactory = childFactory; _onParent = onParent; _onChild = onChild; } - public void ExposeCreateParent(IObservable> source) - => CreateParentSubscription(source); + public void Initialize(ICacheOrchestratorContext context) => _context = context; - protected override void ParentOnNext(IChangeSet changes) + public void OnSourceChangeSet(IChangeSet changes) { Interlocked.Increment(ref ParentCallCount); _onParent?.Invoke(); @@ -338,21 +332,21 @@ protected override void ParentOnNext(IChangeSet changes) foreach (var change in (ChangeSet)changes) { if (change.Reason is ChangeReason.Add or ChangeReason.Update) - AddChildSubscription(MakeChildObservable(_childFactory(change.Key)), change.Key); + _context.Track(change.Key, _childFactory(change.Key)); else if (change.Reason is ChangeReason.Remove) - RemoveChildSubscription(change.Key); + _context.Track(change.Key, null); } } } - protected override void ChildOnNext(string child, int parentKey) + public void OnInner(string child, int parentKey) { _onChild?.Invoke(); ChildCalls.Add((child, parentKey)); _cache.AddOrUpdate(new TestItem(parentKey, child), parentKey); } - protected override void EmitChanges(IObserver> observer) + public void Emit(IObserver> observer) { Interlocked.Increment(ref EmitCallCount); var changes = _cache.CaptureChanges(); @@ -372,4 +366,4 @@ private sealed class TestObserver : IObserver> public void OnError(Exception error) => Error = error; public void OnCompleted() => IsCompleted = true; } -} \ No newline at end of file +} diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index ee81fe58..95f12c96 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -1,40 +1,112 @@ -// 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.Disposables; using System.Reactive.Linq; 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() => Observable.Create>(observer => + source.OrchestrateMany(new Orchestrator(reEvaluator, buffer, scheduler ?? GlobalConfig.DefaultScheduler)) + .SubscribeSafe(observer)); - private readonly IScheduler _scheduler = scheduler ?? GlobalConfig.DefaultScheduler; + private sealed class Orchestrator( + Func> reEvaluator, + TimeSpan? buffer, + IScheduler scheduler) + : ICacheOrchestrator, IChangeSet> + { + private readonly HashSet _sourceTouched = []; + private ICacheOrchestratorContext> _context = null!; + private IChangeSet? _pendingSource; + private List>? _pendingRefreshes; + private IDisposable? _bufferTimer; + private bool _bufferTimerFired; - private readonly IObservable> _source = source ?? throw new ArgumentNullException(nameof(source)); + public void Initialize(ICacheOrchestratorContext> context) => _context = context; - public IObservable> Run() => Observable.Create>( - observer => + public void OnSourceChangeSet(IChangeSet changes) + { + _pendingSource = _pendingSource is null + ? changes + : new ChangeSet(_pendingSource.Concat(changes)); + + foreach (var change in changes.ToConcreteType()) { - var shared = _source.Publish(); + _sourceTouched.Add(change.Key); + + switch (change.Reason) + { + case ChangeReason.Add or ChangeReason.Update: + var item = change.Current; + var key = change.Key; + _context.Track(key, reEvaluator(item, key).Select(_ => new Change(ChangeReason.Refresh, key, item))); + break; - // monitor each item observable and create change - var changes = shared.MergeMany((t, k) => _reEvaluator(t, k).Select(_ => new Change(ChangeReason.Refresh, k, t))); + case ChangeReason.Remove: + _context.Track(change.Key, null); + break; + } + } + } + + public void OnInner(Change refresh, TKey key) + { + // Suppress refresh for any key the source has touched in the same drain. + // Covers Add+Refresh redundancy (the new value was just delivered) and the + // Add+Remove+Refresh sequence (item no longer exists, refresh would be invalid). + if (_sourceTouched.Contains(key)) + { + return; + } + + (_pendingRefreshes ??= []).Add(refresh); + + // Buffered mode: defer the flush to a scheduled drain so refreshes accumulate across + // drains until the window elapses. Bufferless mode flushes on every drain via Emit. + if (buffer is { } window && _bufferTimer is null) + { + _bufferTimer = _context.ScheduleEmit(window, scheduler, MarkBufferFired); + } + } - // 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)); + public void Emit(IObserver> observer) + { + _sourceTouched.Clear(); - // publish refreshes and underlying changes - var queue = new SharedDeliveryQueue(); - var publisher = shared.SynchronizeSafe(queue).Merge(refreshChanges.SynchronizeSafe(queue)).SubscribeSafe(observer); + var source = _pendingSource; + _pendingSource = null; + if (source is { Count: > 0 }) + { + observer.OnNext(source); + } + + var shouldFlush = buffer is null || _bufferTimerFired; + if (!shouldFlush) + { + return; + } + + var refreshes = _pendingRefreshes; + _pendingRefreshes = null; + _bufferTimer = null; + _bufferTimerFired = false; + + if (refreshes is { Count: > 0 }) + { + observer.OnNext(new ChangeSet(refreshes)); + } + } - return new CompositeDisposable(publisher, shared.Connect(), queue); - }); + private void MarkBufferFired() => _bufferTimerFired = true; + } } diff --git a/src/DynamicData/Cache/Internal/FilterOnObservable.cs b/src/DynamicData/Cache/Internal/FilterOnObservable.cs index 6ede12b2..3bf2b406 100644 --- a/src/DynamicData/Cache/Internal/FilterOnObservable.cs +++ b/src/DynamicData/Cache/Internal/FilterOnObservable.cs @@ -2,8 +2,10 @@ // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Reactive; using System.Reactive.Concurrency; using System.Reactive.Linq; +using DynamicData.Internal; namespace DynamicData.Cache.Internal; @@ -11,63 +13,55 @@ internal sealed class FilterOnObservable(IObservable> Run() => Observable.Create>(observer => + public IObservable> Run() => Observable.Defer(() => { - var cache = new ChangeAwareCache(); - - var changes = source.AggregateMany>( - onSourceChangeSet: (parentChanges, track) => + var changes = source.OrchestrateManyChanges( + innerFactory: (item, key) => filterFactory(item, key).DistinctUntilChanged(), + onSourceChange: static (cache, change) => { - foreach (var change in parentChanges.ToConcreteType()) + // 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) { - switch (change.Reason) - { - // Drop any cached value upfront. Synchronous emissions from the new inner observable - // collapse with this Remove inside the same captured changeset. - case ChangeReason.Add or ChangeReason.Update: - cache.Remove(change.Key); - var item = change.Current; - track(change.Key, filterFactory(item, change.Key).DistinctUntilChanged().Select(passes => (item, passes))); - break; - - case ChangeReason.Remove: - cache.Remove(change.Key); - track(change.Key, null); - break; - - case ChangeReason.Refresh: - cache.Refresh(change.Key); - break; - } + cache.Remove(change.Key); + } + else if (change.Reason is ChangeReason.Refresh) + { + cache.Refresh(change.Key); } }, - onInner: (value, key) => + onInner: static (cache, key, item, passes) => { - if (value.Passes) + if (passes) { - cache.AddOrUpdate(value.Item, key); + cache.AddOrUpdate(item, key); } else { cache.Remove(key); } - }, - emit: o => - { - var captured = cache.CaptureChanges(); - if (captured.Count > 0) - { - o.OnNext(captured); - } }); if (buffer is { } window) { - changes = changes.Buffer(window, scheduler ?? GlobalConfig.DefaultScheduler) + var sched = scheduler ?? GlobalConfig.DefaultScheduler; + var quiet = TimeSpan.FromTicks(window.Ticks / 2); + + // Quiescence-based buffering with a hard latency cap: + // - Throttle(window/2): close the buffer after window/2 of source quiet (let bursts settle) + // - Timer(window): cap at the full window so sustained streams cannot starve the boundary + // - Amb picks whichever fires first + // Single-changeset windows are forwarded as-is to avoid an extra ChangeSet allocation. + changes = changes.Publish(published => published.Buffer(() => + published.Throttle(quiet, sched).Select(static _ => Unit.Default) + .Amb(Observable.Timer(window, sched).Select(static _ => Unit.Default)))) .Where(static batches => batches.Count > 0) - .Select(static batches => new ChangeSet(batches.SelectMany(static cs => cs))); + .Select(static batches => batches.Count == 1 + ? batches[0] + : new ChangeSet(batches.SelectMany(static cs => cs))); } - return changes.SubscribeSafe(observer); + return changes; }); } diff --git a/src/DynamicData/Cache/Internal/GroupOnObservable.cs b/src/DynamicData/Cache/Internal/GroupOnObservable.cs index 97533d85..9428ce67 100644 --- a/src/DynamicData/Cache/Internal/GroupOnObservable.cs +++ b/src/DynamicData/Cache/Internal/GroupOnObservable.cs @@ -15,7 +15,7 @@ internal sealed class GroupOnObservable(IObservable> Run() => Observable.Using( resourceFactory: () => new DynamicGrouper(), - observableFactory: grouper => source.AggregateMany>( + observableFactory: grouper => source.OrchestrateMany>( onSourceChangeSet: (changes, track) => { foreach (var change in changes.ToConcreteType()) diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs new file mode 100644 index 00000000..254c1916 --- /dev/null +++ b/src/DynamicData/Cache/Internal/ICacheOrchestrator.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; + +/// +/// Orchestrator contract consumed by the OrchestrateMany primitive. Implementations hold +/// per-subscription state as fields and receive their +/// once via before +/// any other method is called. +/// +/// 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 by . +internal interface ICacheOrchestrator + where TSource : notnull + where TKey : notnull + where TInner : notnull +{ + /// + /// Invoked exactly once by OrchestrateMany before subscribing to the source. Implementations + /// should capture in a private field for use by subsequent method calls. + /// + /// The runtime context, scoped to this subscription's lifetime. + void Initialize(ICacheOrchestratorContext context); + + /// + /// 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 once per drain cycle of the shared delivery queue to flush aggregated state to + /// the downstream . + /// + /// The downstream observer. + void Emit(IObserver observer); +} diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs new file mode 100644 index 00000000..c3acd1bb --- /dev/null +++ b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.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. + +using System.Reactive.Concurrency; + +namespace DynamicData.Cache.Internal; + +/// +/// Runtime context exposed to an +/// via . Provides per-key +/// inner observable lifecycle management, a hook to serialize arbitrary observables through the same +/// shared queue as source and inner notifications, and a primitive to schedule future drain cycles. +/// +/// Source changeset key type. +/// Value type emitted by per-key inner observables. +internal interface ICacheOrchestratorContext + where TKey : notnull + where TInner : notnull +{ + /// + /// Registers, replaces, or removes the inner observable associated with . + /// Pass a non- observable to register or replace; pass + /// to remove. The supplied observable is automatically routed through the shared delivery queue. + /// + /// The source changeset key whose inner subscription should change. + /// The new inner observable, or to remove. + void Track(TKey key, IObservable? observable); + + /// + /// Wraps with the shared delivery queue's synchronization gate so + /// that any operators chained downstream (e.g. side-effecting Do calls) run under the same + /// serialization that source and inner notifications already enjoy. + /// + /// 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); + + /// + /// Schedules a one-shot future drain cycle: after elapses on + /// , a notification enters the shared delivery queue. If + /// is provided it is invoked synchronously during that drain (before + /// the drain's call), + /// allowing orchestrators to signal state to themselves before flushing aggregated work downstream. + /// + /// The delay before the drain is triggered. + /// Scheduler used for the timer. + /// Optional callback invoked inside the triggered drain, before Emit. + /// A disposable that cancels the pending drain if disposed before . + IDisposable ScheduleEmit(TimeSpan dueTime, IScheduler scheduler, Action? onFire = null); +} diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs new file mode 100644 index 00000000..7ace2139 --- /dev/null +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs @@ -0,0 +1,100 @@ +// 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. +/// +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 owns the per-subscription + /// state and is wired to an via + /// . + /// + /// 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. + /// The orchestrator implementation. + /// An observable that orchestrates source and inner activity into a single result stream. + public static IObservable OrchestrateMany( + this IObservable> source, + ICacheOrchestrator orchestrator) + where TSource : notnull + where TKey : notnull + where TInner : notnull => + new Orchestrator(source, orchestrator).Run(); + + /// + /// Convenience overload that wraps three lambdas into an + /// and delegates to . + /// Does not expose or + /// ; operators that need either + /// must implement 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 by . + /// The keyed source changeset stream. + /// Invoked for each source changeset, paired with a track callback. + /// Invoked for each value emitted by a tracked inner observable, paired with its key. + /// Invoked once per drain cycle to flush the aggregated state to the observer. + /// An observable that orchestrates source and inner activity into a single result stream. + public static IObservable OrchestrateMany( + this IObservable> source, + Action, Action?>> onSourceChangeSet, + Action onInner, + Action> emit) + where TSource : notnull + where TKey : notnull + where TInner : notnull => + source.OrchestrateMany(AsCacheOrchestrator(onSourceChangeSet, onInner, emit)); + + /// + /// Wraps three lambdas into an . + /// + /// 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. + /// Invoked for each source changeset, paired with a track callback. + /// Invoked for each value emitted by a tracked inner observable, paired with its key. + /// Invoked once per drain cycle to flush the aggregated state to the observer. + /// An orchestrator that routes the three callbacks through its . + public static ICacheOrchestrator AsCacheOrchestrator( + Action, Action?>> onSourceChangeSet, + Action onInner, + Action> emit) + where TSource : notnull + where TKey : notnull + where TInner : notnull => + new LambdaCacheOrchestrator(onSourceChangeSet, onInner, emit); + + private sealed class LambdaCacheOrchestrator( + Action, Action?>> onSourceChangeSet, + Action onInner, + Action> emit) + : ICacheOrchestrator + where TSource : notnull + where TKey : notnull + where TInner : notnull + { + private ICacheOrchestratorContext _context = null!; + + public void Initialize(ICacheOrchestratorContext context) => _context = context; + + public void OnSourceChangeSet(IChangeSet changes) => onSourceChangeSet(changes, _context.Track); + + public void OnInner(TInner value, TKey key) => onInner(value, key); + + public void Emit(IObserver observer) => emit(observer); + } +} diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs new file mode 100644 index 00000000..9cf36cad --- /dev/null +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs @@ -0,0 +1,95 @@ +// 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 changeset events and inner emissions + /// are coalesced into a single downstream changeset per drain cycle. Specialization of + /// + /// for the "mirror manipulator" shape used by FilterOnObservable, TransformOnObservable, and + /// similar operators 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 key of the output changeset). + /// 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. Receives the output cache and the source change; the caller + /// decides how (and whether) the source event mutates the output cache. Always invoked before + /// the corresponding inner subscription is created or torn down. + /// + /// + /// Invoked once per inner emission. Receives the output cache, the source key, the current source + /// item, and the value emitted by the inner observable. The caller decides how the inner emission + /// mutates the output cache. + /// + /// An observable changeset where every emission is the captured changes from one drain cycle. + public static IObservable> OrchestrateManyChanges( + 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 => + Observable.Create>(observer => + source.OrchestrateMany(new ChangesOrchestrator(innerFactory, onSourceChange, onInner)) + .SubscribeSafe(observer)); + + private sealed class ChangesOrchestrator( + Func> innerFactory, + Action, Change> onSourceChange, + Action, TKey, TSource, TInner> onInner) + : ICacheOrchestrator> + where TSource : notnull + where TKey : notnull + where TInner : notnull + where TOutput : notnull + { + private readonly ChangeAwareCache _cache = new(); + private ICacheOrchestratorContext _context = null!; + + public void Initialize(ICacheOrchestratorContext context) => _context = context; + + public void OnSourceChangeSet(IChangeSet changes) + { + foreach (var change in changes.ToConcreteType()) + { + onSourceChange(_cache, change); + + switch (change.Reason) + { + case ChangeReason.Add or ChangeReason.Update: + var item = change.Current; + _context.Track(change.Key, innerFactory(item, change.Key).Select(value => (Item: item, Value: value))); + break; + + case ChangeReason.Remove: + _context.Track(change.Key, null); + break; + } + } + } + + public void OnInner((TSource Item, TInner Value) value, TKey key) => onInner(_cache, key, value.Item, value.Value); + + public void Emit(IObserver> observer) + { + var captured = _cache.CaptureChanges(); + if (captured.Count > 0) + { + observer.OnNext(captured); + } + } + } +} diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs new file mode 100644 index 00000000..06fd4f87 --- /dev/null +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs @@ -0,0 +1,110 @@ +// 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-source-key inner observables that themselves emit cache changesets, merging + /// the live set of all such inner streams into a single output changeset. Specialization of + /// + /// for the cache-source-to-cache-merged shape used by MergeManyCacheChangeSets, + /// MergeManyCacheChangeSetsSourceCompare, and TransformManyAsync. + /// + /// 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 Refresh on the source 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> OrchestrateManyMerged( + 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 => + Observable.Create>(observer => + source.OrchestrateMany(new MergedOrchestrator(changeSetSelector, equalityComparer, comparer, reevalOnRefresh)) + .SubscribeSafe(observer)); + + private sealed class MergedOrchestrator : ICacheOrchestrator, 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; + private ICacheOrchestratorContext> _context = null!; + + public MergedOrchestrator( + Func>> changeSetSelector, + IEqualityComparer? equalityComparer, + IComparer? comparer, + bool reevalOnRefresh) + { + _changeSetSelector = changeSetSelector; + _reevalOnRefresh = reevalOnRefresh; + _tracker = new ChangeSetMergeTracker(() => _cache.Items, comparer, equalityComparer); + } + + public void Initialize(ICacheOrchestratorContext> context) => _context = context; + + public void OnSourceChangeSet(IChangeSet changes) + { + foreach (var change in changes.ToConcreteType()) + { + switch (change.Reason) + { + case ChangeReason.Add or ChangeReason.Update: + var previous = _cache.Lookup(change.Key); + var entry = new ChangeSetCache(_context.Serialize(_changeSetSelector(change.Current, change.Key).IgnoreSameReferenceUpdate())); + _cache.AddOrUpdate(entry, change.Key); + _context.Track(change.Key, entry.Source); + if (previous.HasValue) + { + _tracker.RemoveItems(previous.Value.Cache.KeyValues); + } + break; + + case ChangeReason.Remove: + if (_cache.Lookup(change.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(change.Key); + _tracker.RemoveItems(removed.Value.Cache.KeyValues); + } + + _context.Track(change.Key, null); + break; + + case ChangeReason.Refresh when _reevalOnRefresh: + if (_cache.Lookup(change.Key) is { HasValue: true } current) + { + _tracker.RefreshItems(current.Value.Cache.Keys); + } + break; + } + } + } + + public void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); + + public void Emit(IObserver> observer) => _tracker.EmitChanges(observer); + } +} diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs new file mode 100644 index 00000000..5ebb0b1e --- /dev/null +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs @@ -0,0 +1,92 @@ +// 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 +{ + /// + /// Orchestrates per-source-key inner observables that themselves emit list changesets, merging + /// the live set of all such inner streams into a single output list changeset. Specialization of + /// + /// for the cache-source-to-list-merged shape used by MergeManyListChangeSets in Cache/Internal/. + /// + /// 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 used when storing per-source-key snapshots of inner contents. + /// An observable list changeset representing the merged union of all live inner changesets. + public static IObservable> OrchestrateManyMergedList( + this IObservable> source, + Func>> changeSetSelector, + IEqualityComparer? equalityComparer = null) + where TSource : notnull + where TKey : notnull + where TDest : notnull => + Observable.Create>(observer => + source.OrchestrateMany(new MergedListOrchestrator(changeSetSelector, equalityComparer)) + .SubscribeSafe(observer)); + + private sealed class MergedListOrchestrator : ICacheOrchestrator, 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; + private ICacheOrchestratorContext> _context = null!; + + public MergedListOrchestrator( + Func>> changeSetSelector, + IEqualityComparer? equalityComparer) + { + _changeSetSelector = changeSetSelector; + _equalityComparer = equalityComparer; + } + + public void Initialize(ICacheOrchestratorContext> context) => _context = context; + + public void OnSourceChangeSet(IChangeSet changes) + { + foreach (var change in changes.ToConcreteType()) + { + switch (change.Reason) + { + case ChangeReason.Add or ChangeReason.Update: + var entry = new ClonedListChangeSet(_context.Serialize(_changeSetSelector(change.Current, change.Key).RemoveIndex()), _equalityComparer); + if (_entries.TryGetValue(change.Key, out var previous)) + { + _entries.Remove(change.Key); + _tracker.RemoveItems(previous.List); + } + + _entries[change.Key] = entry; + _context.Track(change.Key, entry.Source); + break; + + case ChangeReason.Remove: + if (_entries.TryGetValue(change.Key, out var removed)) + { + _entries.Remove(change.Key); + _tracker.RemoveItems(removed.List); + } + + _context.Track(change.Key, null); + break; + } + } + } + + public void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); + + public void Emit(IObserver> observer) => _tracker.EmitChanges(observer); + } +} diff --git a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSets.cs b/src/DynamicData/Cache/Internal/MergeManyCacheChangeSets.cs index 551cc8bb..74a2c83b 100644 --- a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSets.cs +++ b/src/DynamicData/Cache/Internal/MergeManyCacheChangeSets.cs @@ -2,76 +2,23 @@ // 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. +/// Operator that is similar to MergeMany but intelligently handles Cache ChangeSets. /// -internal sealed class MergeManyCacheChangeSets(IObservable> source, Func>> changeSetSelector, IEqualityComparer? equalityComparer, IComparer? comparer) +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); - } + public IObservable> Run() => + source.OrchestrateManyMerged(changeSetSelector, equalityComparer, comparer); } diff --git a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs b/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs index a49ad822..29120dcd 100644 --- a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs +++ b/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs @@ -2,7 +2,6 @@ // 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; @@ -11,88 +10,30 @@ 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) +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)) + source.OrchestrateManyMerged( + changeSetSelector: (obj, key) => selector(obj, key).Transform(dest => new ParentChildEntry(obj, dest)), + equalityComparer: _equalityComparer, + comparer: _comparer, + reevalOnRefresh: 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 diff --git a/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs b/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs index bd7b18f4..c0bbe733 100644 --- a/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs +++ b/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs @@ -2,70 +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; using DynamicData.Internal; -using DynamicData.List.Internal; namespace DynamicData.Cache.Internal; /// -/// Operator that is similiar to MergeMany but intelligently handles List ChangeSets. +/// Operator that is similar to MergeMany but intelligently handles List ChangeSets. /// -internal sealed class MergeManyListChangeSets(IObservable> source, Func>> selector, IEqualityComparer? equalityComparer) +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); - } + public IObservable> Run() => + source.OrchestrateManyMergedList(selector, equalityComparer); } diff --git a/src/DynamicData/Internal/AggregateManyExtensions.cs b/src/DynamicData/Cache/Internal/Orchestrator.cs similarity index 50% rename from src/DynamicData/Internal/AggregateManyExtensions.cs rename to src/DynamicData/Cache/Internal/Orchestrator.cs index a810baaa..036a835f 100644 --- a/src/DynamicData/Internal/AggregateManyExtensions.cs +++ b/src/DynamicData/Cache/Internal/Orchestrator.cs @@ -3,58 +3,40 @@ // See the LICENSE file in the project root for full license information. using System.Diagnostics; +using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; +using DynamicData.Internal; -namespace DynamicData.Internal; +namespace DynamicData.Cache.Internal; /// -/// Provides the operator: a delegate-driven entry point -/// that subscribes to a keyed source changeset, manages per-key inner subscriptions, and coalesces source and inner -/// notifications into a single downstream emission per drain cycle of an internal . +/// Drives an against a source +/// changeset. returns an that constructs a +/// fresh per-subscription on each subscribe, so all per-subscription +/// state owned by the is recreated on every subscribe. /// -internal static class AggregateManyExtensions +/// 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. +internal sealed class Orchestrator( + IObservable> source, + ICacheOrchestrator orchestrator) + where TSource : notnull + where TKey : notnull + where TInner : notnull { - /// - /// Aggregates a keyed source changeset and a dynamic set of per-key inner observables into a single result stream. - /// - /// 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 by . - /// The keyed source changeset stream. - /// - /// Invoked for each source changeset, paired with a track callback that registers, - /// replaces, or removes the inner observable for a key. Pass a non- - /// observable to register or replace; pass to remove. The callback - /// routes the inner observable through the shared delivery queue so callers do not need - /// to synchronize it themselves. - /// - /// Invoked for each value emitted by a tracked inner observable, paired with its key. - /// Invoked once per drain cycle to flush the aggregated state to the observer. - /// An observable that aggregates source and inner activity into a single result stream. - public static IObservable AggregateMany( - this IObservable> source, - Action, Action?>> onSourceChangeSet, - Action onInner, - Action> emit) - where TSource : notnull - where TKey : notnull - where TInner : notnull => - Observable.Create(observer => new Subscription(source, observer, onSourceChangeSet, onInner, emit)); - - private sealed class Subscription : IDisposable - where TSource : notnull - where TKey : notnull - where TInner : notnull + public IObservable Run() => + Observable.Create(observer => new Subscription(source, observer, orchestrator)); + + private sealed class Subscription : ICacheOrchestratorContext, IDisposable { private readonly KeyedDisposable _innerSubscriptions = new(); private readonly SingleAssignmentDisposable _sourceSubscription = new(); private readonly SharedDeliveryQueue _queue; private readonly IObserver _observer; - private readonly Action, Action?>> _onSource; - private readonly Action _onInner; - private readonly Action> _emit; + private readonly ICacheOrchestrator _orchestrator; private int _subscriptionCounter = 1; private bool _isCompleted; private bool _hasTerminated; @@ -63,22 +45,13 @@ private sealed class Subscription : IDisposable public Subscription( IObservable> source, IObserver observer, - Action, Action?>> onSource, - Action onInner, - Action> emit) + ICacheOrchestrator orchestrator) { _observer = observer; - _onSource = onSource; - _onInner = onInner; - _emit = emit; + _orchestrator = orchestrator; _queue = new SharedDeliveryQueue(onDrainComplete: OnDrainComplete); - - _sourceSubscription.Disposable = source - .SynchronizeSafe(_queue) - .SubscribeSafe( - onNext: changes => _onSource(changes, Track), - onError: TerminalError, - onCompleted: DecrementSubscriptionCount); + _orchestrator.Initialize(this); + SubscribeToSource(source); } public void Dispose() @@ -94,7 +67,7 @@ public void Dispose() _innerSubscriptions.Dispose(); } - private void Track(TKey key, IObservable? observable) + public void Track(TKey key, IObservable? observable) { if (observable is null) { @@ -115,14 +88,58 @@ private void Track(TKey key, IObservable? observable) .SynchronizeSafe(_queue) .Finally(DecrementSubscriptionCount) .SubscribeSafe( - onNext: value => _onInner(value, key), + onNext: value => OnInner(value, key), onError: TerminalError, onCompleted: () => _innerSubscriptions.Remove(key)); } + public IObservable Serialize(IObservable observable) => observable.SynchronizeSafe(_queue); + + public IDisposable ScheduleEmit(TimeSpan dueTime, IScheduler scheduler, Action? onFire = null) => + Observable.Timer(dueTime, scheduler) + .SynchronizeSafe(_queue) + .Subscribe(_ => onFire?.Invoke()); + + private void SubscribeToSource(IObservable> source) => + _sourceSubscription.Disposable = source + .SynchronizeSafe(_queue) + .SubscribeSafe( + onNext: OnSourceChangeSet, + onError: TerminalError, + onCompleted: DecrementSubscriptionCount); + + private void OnSourceChangeSet(IChangeSet changes) + { + try + { + _orchestrator.OnSourceChangeSet(changes); + } + catch (Exception error) + { + TerminalError(error); + } + } + + private void OnInner(TInner value, TKey key) + { + try + { + _orchestrator.OnInner(value, key); + } + catch (Exception error) + { + TerminalError(error); + } + } + private void OnDrainComplete() { - _emit(_observer); + if (_hasTerminated) + { + return; + } + + _orchestrator.Emit(_observer); if (Volatile.Read(ref _isCompleted) && !_hasTerminated) { @@ -133,6 +150,11 @@ private void OnDrainComplete() private void TerminalError(Exception error) { + if (_hasTerminated) + { + return; + } + _hasTerminated = true; _observer.OnError(error); } diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index b202cb30..0cb51bc8 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -1,94 +1,95 @@ -// 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() => Observable.Create>(observer => + source.OrchestrateMany(new Orchestrator(transformer, equalityComparer, comparer, errorHandler)) + .SubscribeSafe(observer)); - // Maintains state for a single subscription - private sealed class Subscription : CacheParentSubscription, TKey, IChangeSet, IChangeSet> + private sealed class Orchestrator : ICacheOrchestrator, IChangeSet> { private readonly Cache, TKey> _cache = new(); - private readonly ChangeSetMergeTracker _changeSetMergeTracker; + private readonly ChangeSetMergeTracker _tracker; + private readonly Func>>> _transformer; + private readonly Action>? _errorHandler; + private ICacheOrchestratorContext> _context = null!; - public Subscription(IObservable> source, Func>>> transform, IObserver> observer, IEqualityComparer? equalityComparer, IComparer? comparer, Action>? errorHandler = null) - : base(observer) + public Orchestrator( + Func>>> transformer, + IEqualityComparer? equalityComparer, + IComparer? comparer, + Action>? errorHandler) { - // Transform Helper - async Task>> ErrorHandlingTransform(TSource obj, TKey key) - { - try - { - return await transform(obj, key).ConfigureAwait(false); - } - catch (Exception e) - { - errorHandler.Invoke(new Error(e, obj, key)); - return Observable.Empty>(); - } - } - - ChangeSetCache Transformer(TSource obj, TKey key) => - new(MakeChildObservable(Observable.Defer(() => transform(obj, key)))); - - ChangeSetCache SafeTransformer(TSource obj, TKey key) => - new(MakeChildObservable(Observable.Defer(() => ErrorHandlingTransform(obj, key)))); - - _changeSetMergeTracker = new(() => _cache.Items, comparer, equalityComparer); - - if (errorHandler is null) - { - CreateParentSubscription(source.Transform(Transformer)); - } - else - { - CreateParentSubscription(source.Transform(SafeTransformer)); - } + _transformer = transformer; + _errorHandler = errorHandler; + _tracker = new ChangeSetMergeTracker(() => _cache.Items, comparer, equalityComparer); } - protected override void ParentOnNext(IChangeSet, TKey> changes) + public void Initialize(ICacheOrchestratorContext> context) => _context = context; + + public void OnSourceChangeSet(IChangeSet 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) + var previous = _cache.Lookup(change.Key); + var entry = new ChangeSetCache(_context.Serialize(BuildInner(change.Current, change.Key))); + _cache.AddOrUpdate(entry, change.Key); + _context.Track(change.Key, entry.Source); + if (previous.HasValue) { - _changeSetMergeTracker.RemoveItems(change.Previous.Value.Cache.KeyValues); + _tracker.RemoveItems(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); + if (_cache.Lookup(change.Key) is { HasValue: true } removed) + { + _cache.Remove(change.Key); + _tracker.RemoveItems(removed.Value.Cache.KeyValues); + } + + _context.Track(change.Key, null); break; } } } - protected override void ChildOnNext(IChangeSet child, TKey parentKey) => - _changeSetMergeTracker.ProcessChangeSet(child); + public void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - protected override void EmitChanges(IObserver> observer) => - _changeSetMergeTracker.EmitChanges(observer); + public void Emit(IObserver> observer) => _tracker.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 35508e5a..c7f32438 100644 --- a/src/DynamicData/Cache/Internal/TransformOnObservable.cs +++ b/src/DynamicData/Cache/Internal/TransformOnObservable.cs @@ -16,7 +16,7 @@ public IObservable> Run() => Observable.Defer(() { var cache = new ChangeAwareCache(); - return source.AggregateMany>( + return source.OrchestrateMany>( onSourceChangeSet: (changes, track) => { foreach (var change in changes.ToConcreteType()) diff --git a/src/DynamicData/Cache/ObservableCache.cs b/src/DynamicData/Cache/ObservableCache.cs index 5cbeca98..ed1bd905 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.cs b/src/DynamicData/Cache/ObservableCacheEx.cs index 1c04772a..d0e2d8a1 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.cs @@ -404,6 +404,7 @@ public static IObservable> AutoRefresh @@ -433,7 +434,13 @@ public static IObservable> AutoRefresh 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. diff --git a/src/DynamicData/Internal/CacheParentSubscription.cs b/src/DynamicData/Internal/CacheParentSubscription.cs deleted file mode 100644 index 3a33143d..00000000 --- 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"); - } -} From 51fdc6ba9cf3d50526da992a969551e6918210bd Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sat, 6 Jun 2026 13:06:24 -0700 Subject: [PATCH 05/30] Add CacheChangeHandlerBase, eliminate ScheduleEmit Adds an optional CacheChangeHandlerBase abstract class that implements ICacheOrchestrator.OnSourceChangeSet by dispatching each Change to one of five protected virtual hooks (OnItemAdded/Updated/Removed/Refreshed + OnChangeSetProcessed). Orchestrators that inherit it stop hand-rolling the foreach+switch over change.Reason. Migrated to the base class: - ChangesOrchestrator (OrchestrateManyChanges) - MergedOrchestrator (OrchestrateManyMerged) - MergedListOrchestrator (OrchestrateManyMergedList) - TransformManyAsync.Orchestrator - AutoRefresh.UnbufferedOrchestrator + AutoRefresh.BufferedOrchestrator Removed ICacheOrchestratorContext.ScheduleEmit. Only AutoRefresh used it, and the buffered path now uses Subject.Buffer routed through Context.Serialize to keep all activity under the shared queue without needing a dedicated 'schedule a future drain' primitive. AutoRefresh.WithBuffering behavior change: - Now dedups Refresh changes within each buffer window (one Refresh per key per window), matching Jake's choice in the upstream WIP. - Source events and refreshes remain independent (refreshes for keys source touched in the same window are still emitted). AutoRefresh.WithoutBuffering preserves the #1099 fix: refreshes for keys the source touched in the same drain cycle are suppressed. All 2386 tests pass, 0 skipped. --- src/DynamicData/Cache/Internal/AutoRefresh.cs | 187 ++++++++++++------ .../Cache/Internal/CacheChangeHandlerBase.cs | 79 ++++++++ .../Internal/ICacheOrchestratorContext.cs | 24 +-- ...bservableCacheEx.OrchestrateManyChanges.cs | 48 +++-- ...ObservableCacheEx.OrchestrateManyMerged.cs | 72 ++++--- ...rvableCacheEx.OrchestrateManyMergedList.cs | 57 +++--- .../Cache/Internal/Orchestrator.cs | 6 - .../Cache/Internal/TransformManyAsync.cs | 57 +++--- 8 files changed, 325 insertions(+), 205 deletions(-) create mode 100644 src/DynamicData/Cache/Internal/CacheChangeHandlerBase.cs diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index 95f12c96..40476ceb 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -4,6 +4,7 @@ using System.Reactive.Concurrency; using System.Reactive.Linq; +using System.Reactive.Subjects; namespace DynamicData.Cache.Internal; @@ -16,97 +17,167 @@ internal sealed class AutoRefresh( where TKey : notnull { public IObservable> Run() => Observable.Create>(observer => - source.OrchestrateMany(new Orchestrator(reEvaluator, buffer, scheduler ?? GlobalConfig.DefaultScheduler)) - .SubscribeSafe(observer)); - - private sealed class Orchestrator( - Func> reEvaluator, - TimeSpan? buffer, - IScheduler scheduler) - : ICacheOrchestrator, IChangeSet> + { + var orchestrator = buffer is { } window + ? (CacheChangeHandlerBase, IChangeSet>)new BufferedOrchestrator(reEvaluator, window, scheduler ?? GlobalConfig.DefaultScheduler) + : new UnbufferedOrchestrator(reEvaluator); + return source.OrchestrateMany(orchestrator).SubscribeSafe(observer); + }); + + /// + /// Unbuffered AutoRefresh: forwards source changes verbatim and emits each refresh notification + /// individually as a single-change . Refreshes for keys the + /// source has touched within the same drain cycle are suppressed (fixes #1099: the sync + /// Add+Refresh scenario where a reevaluator fires synchronously during item subscription would + /// otherwise emit both Add and Refresh for the same key). + /// + private sealed class UnbufferedOrchestrator(Func> reEvaluator) + : CacheChangeHandlerBase, IChangeSet> { private readonly HashSet _sourceTouched = []; - private ICacheOrchestratorContext> _context = null!; private IChangeSet? _pendingSource; private List>? _pendingRefreshes; - private IDisposable? _bufferTimer; - private bool _bufferTimerFired; - - public void Initialize(ICacheOrchestratorContext> context) => _context = context; - public void OnSourceChangeSet(IChangeSet changes) + public override void OnInner(Change refresh, TKey key) { - _pendingSource = _pendingSource is null - ? changes - : new ChangeSet(_pendingSource.Concat(changes)); - - foreach (var change in changes.ToConcreteType()) + if (_sourceTouched.Contains(key)) { - _sourceTouched.Add(change.Key); - - switch (change.Reason) - { - case ChangeReason.Add or ChangeReason.Update: - var item = change.Current; - var key = change.Key; - _context.Track(key, reEvaluator(item, key).Select(_ => new Change(ChangeReason.Refresh, key, item))); - break; - - case ChangeReason.Remove: - _context.Track(change.Key, null); - break; - } + return; } + + (_pendingRefreshes ??= []).Add(refresh); } - public void OnInner(Change refresh, TKey key) + public override void Emit(IObserver> observer) { - // Suppress refresh for any key the source has touched in the same drain. - // Covers Add+Refresh redundancy (the new value was just delivered) and the - // Add+Remove+Refresh sequence (item no longer exists, refresh would be invalid). - if (_sourceTouched.Contains(key)) + _sourceTouched.Clear(); + + var pending = _pendingSource; + _pendingSource = null; + if (pending is { Count: > 0 }) { - return; + observer.OnNext(pending); } - (_pendingRefreshes ??= []).Add(refresh); - - // Buffered mode: defer the flush to a scheduled drain so refreshes accumulate across - // drains until the window elapses. Bufferless mode flushes on every drain via Emit. - if (buffer is { } window && _bufferTimer is null) + var refreshes = _pendingRefreshes; + _pendingRefreshes = null; + if (refreshes is { Count: > 0 }) { - _bufferTimer = _context.ScheduleEmit(window, scheduler, MarkBufferFired); + observer.OnNext(new ChangeSet(refreshes)); } } - public void Emit(IObserver> observer) + protected override void OnItemAdded(TObject item, TKey key) { - _sourceTouched.Clear(); + _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) => OnItemAdded(current, key); - var source = _pendingSource; + protected override void OnItemRemoved(TObject item, TKey key) + { + _sourceTouched.Add(key); + Context.Track(key, null); + } + + protected override void OnItemRefreshed(TObject item, TKey key) => _sourceTouched.Add(key); + + protected override void OnChangeSetProcessed(IChangeSet changes) => + _pendingSource = _pendingSource is null + ? changes + : new ChangeSet(_pendingSource.Concat(changes)); + } + + /// + /// Buffered AutoRefresh: source changes flow through immediately; refreshes are collected into a + /// time-bounded buffer and emitted as a single per window, + /// deduplicated by key within the window. The buffered stream is routed through + /// so the per-window flush runs + /// under the same serialization gate as source and inner events. + /// + private sealed class BufferedOrchestrator : CacheChangeHandlerBase, IChangeSet>, IDisposable + { + private readonly Subject> _refreshes = new(); + private readonly HashSet _dedupBuffer = []; + private readonly Func> _reEvaluator; + private readonly TimeSpan _window; + private readonly IScheduler _scheduler; + private IChangeSet? _pendingSource; + private List>? _bufferedRefreshes; + private IDisposable? _bufferSubscription; + private bool _shouldFlushBuffered; + + public BufferedOrchestrator(Func> reEvaluator, TimeSpan window, IScheduler scheduler) + { + _reEvaluator = reEvaluator; + _window = window; + _scheduler = scheduler; + } + + public void Dispose() + { + _bufferSubscription?.Dispose(); + _refreshes.Dispose(); + } + + public override void OnInner(Change refresh, TKey key) + { + _bufferSubscription ??= Context.Serialize(_refreshes.Buffer(_window, _scheduler)).Subscribe(OnRefreshBatch); + _refreshes.OnNext(refresh); + } + + public override void Emit(IObserver> observer) + { + var pending = _pendingSource; _pendingSource = null; - if (source is { Count: > 0 }) + if (pending is { Count: > 0 }) { - observer.OnNext(source); + observer.OnNext(pending); } - var shouldFlush = buffer is null || _bufferTimerFired; - if (!shouldFlush) + if (!_shouldFlushBuffered) { return; } - var refreshes = _pendingRefreshes; - _pendingRefreshes = null; - _bufferTimer = null; - _bufferTimerFired = false; - + _shouldFlushBuffered = false; + var refreshes = _bufferedRefreshes; + _bufferedRefreshes = null; if (refreshes is { Count: > 0 }) { observer.OnNext(new ChangeSet(refreshes)); } } - private void MarkBufferFired() => _bufferTimerFired = true; + protected override void OnItemAdded(TObject item, TKey key) => + Context.Track(key, _reEvaluator(item, key).Select(_ => new Change(ChangeReason.Refresh, key, item))); + + protected override void OnItemRemoved(TObject item, TKey key) => Context.Track(key, null); + + protected override void OnChangeSetProcessed(IChangeSet changes) => + _pendingSource = _pendingSource is null + ? changes + : new ChangeSet(_pendingSource.Concat(changes)); + + private void OnRefreshBatch(IList> batch) + { + if (batch.Count == 0) + { + return; + } + + var batched = _bufferedRefreshes ??= []; + foreach (var change in batch) + { + if (_dedupBuffer.Add(change.Key)) + { + batched.Add(change); + } + } + + _dedupBuffer.Clear(); + _shouldFlushBuffered = true; + } } } diff --git a/src/DynamicData/Cache/Internal/CacheChangeHandlerBase.cs b/src/DynamicData/Cache/Internal/CacheChangeHandlerBase.cs new file mode 100644 index 00000000..7335fd66 --- /dev/null +++ b/src/DynamicData/Cache/Internal/CacheChangeHandlerBase.cs @@ -0,0 +1,79 @@ +// 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. +/// The base implementation of walks the changeset and dispatches each +/// change to the corresponding protected virtual method, then invokes +/// for end-of-changeset bookkeeping. Captures the +/// in a property accessible to derived classes. +/// +/// 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 by . +internal abstract class CacheChangeHandlerBase + : ICacheOrchestrator + where TSource : notnull + where TKey : notnull + where TInner : notnull +{ + private ICacheOrchestratorContext _context = null!; + + protected ICacheOrchestratorContext Context => _context; + + public void Initialize(ICacheOrchestratorContext context) => _context = context; + + public 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; + } + } + + OnChangeSetProcessed(changes); + } + + public abstract void OnInner(TInner value, TKey key); + + public abstract void Emit(IObserver observer); + + 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) + { + } + + protected virtual void OnChangeSetProcessed(IChangeSet changes) + { + } +} diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs index c3acd1bb..4cefd1fb 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs @@ -2,15 +2,13 @@ // 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; - namespace DynamicData.Cache.Internal; /// /// Runtime context exposed to an /// via . Provides per-key -/// inner observable lifecycle management, a hook to serialize arbitrary observables through the same -/// shared queue as source and inner notifications, and a primitive to schedule future drain cycles. +/// 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. @@ -29,24 +27,12 @@ internal interface ICacheOrchestratorContext /// /// Wraps with the shared delivery queue's synchronization gate so - /// that any operators chained downstream (e.g. side-effecting Do calls) run under the same - /// serialization that source and inner notifications already enjoy. + /// that any operators chained downstream (e.g. side-effecting Do calls, time-based + /// buffering of values that will be re-emitted) run under the same serialization that source + /// and inner notifications already enjoy. /// /// 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); - - /// - /// Schedules a one-shot future drain cycle: after elapses on - /// , a notification enters the shared delivery queue. If - /// is provided it is invoked synchronously during that drain (before - /// the drain's call), - /// allowing orchestrators to signal state to themselves before flushing aggregated work downstream. - /// - /// The delay before the drain is triggered. - /// Scheduler used for the timer. - /// Optional callback invoked inside the triggered drain, before Emit. - /// A disposable that cancels the pending drain if disposed before . - IDisposable ScheduleEmit(TimeSpan dueTime, IScheduler scheduler, Action? onFire = null); } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs index 9cf36cad..285cea1b 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs @@ -50,46 +50,44 @@ private sealed class ChangesOrchestrator( Func> innerFactory, Action, Change> onSourceChange, Action, TKey, TSource, TInner> onInner) - : ICacheOrchestrator> + : CacheChangeHandlerBase> where TSource : notnull where TKey : notnull where TInner : notnull where TOutput : notnull { private readonly ChangeAwareCache _cache = new(); - private ICacheOrchestratorContext _context = null!; - public void Initialize(ICacheOrchestratorContext context) => _context = context; + public override void OnInner((TSource Item, TInner Value) value, TKey key) => onInner(_cache, key, value.Item, value.Value); - public void OnSourceChangeSet(IChangeSet changes) + public override void Emit(IObserver> observer) { - foreach (var change in changes.ToConcreteType()) + var captured = _cache.CaptureChanges(); + if (captured.Count > 0) { - onSourceChange(_cache, change); - - switch (change.Reason) - { - case ChangeReason.Add or ChangeReason.Update: - var item = change.Current; - _context.Track(change.Key, innerFactory(item, change.Key).Select(value => (Item: item, Value: value))); - break; - - case ChangeReason.Remove: - _context.Track(change.Key, null); - break; - } + observer.OnNext(captured); } } - public void OnInner((TSource Item, TInner Value) value, TKey key) => onInner(_cache, key, value.Item, value.Value); + 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))); + } - public void Emit(IObserver> observer) + protected override void OnItemUpdated(TSource current, TSource previous, TKey key) { - var captured = _cache.CaptureChanges(); - if (captured.Count > 0) - { - observer.OnNext(captured); - } + 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.Track(key, null); + } + + protected override void OnItemRefreshed(TSource item, TKey key) => + onSourceChange(_cache, new Change(ChangeReason.Refresh, key, item)); } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs index 06fd4f87..6a1a200e 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs @@ -39,7 +39,7 @@ public static IObservable> OrchestrateManyMerged(changeSetSelector, equalityComparer, comparer, reevalOnRefresh)) .SubscribeSafe(observer)); - private sealed class MergedOrchestrator : ICacheOrchestrator, IChangeSet> + private sealed class MergedOrchestrator : CacheChangeHandlerBase, IChangeSet> where TSource : notnull where TKey : notnull where TDest : notnull @@ -49,7 +49,6 @@ private sealed class MergedOrchestrator : ICache private readonly ChangeSetMergeTracker _tracker; private readonly Func>> _changeSetSelector; private readonly bool _reevalOnRefresh; - private ICacheOrchestratorContext> _context = null!; public MergedOrchestrator( Func>> changeSetSelector, @@ -62,49 +61,48 @@ public MergedOrchestrator( _tracker = new ChangeSetMergeTracker(() => _cache.Items, comparer, equalityComparer); } - public void Initialize(ICacheOrchestratorContext> context) => _context = context; + public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public void OnSourceChangeSet(IChangeSet changes) + public override void Emit(IObserver> observer) => _tracker.EmitChanges(observer); + + protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); + + protected override void OnItemUpdated(TSource current, TSource previous, TKey key) { - foreach (var change in changes.ToConcreteType()) + var prior = _cache.Lookup(key); + SubscribeChild(current, key); + if (prior.HasValue) { - switch (change.Reason) - { - case ChangeReason.Add or ChangeReason.Update: - var previous = _cache.Lookup(change.Key); - var entry = new ChangeSetCache(_context.Serialize(_changeSetSelector(change.Current, change.Key).IgnoreSameReferenceUpdate())); - _cache.AddOrUpdate(entry, change.Key); - _context.Track(change.Key, entry.Source); - if (previous.HasValue) - { - _tracker.RemoveItems(previous.Value.Cache.KeyValues); - } - break; + _tracker.RemoveItems(prior.Value.Cache.KeyValues); + } + } - case ChangeReason.Remove: - if (_cache.Lookup(change.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(change.Key); - _tracker.RemoveItems(removed.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.Track(change.Key, null); - break; + Context.Track(key, null); + } - case ChangeReason.Refresh when _reevalOnRefresh: - if (_cache.Lookup(change.Key) is { HasValue: true } current) - { - _tracker.RefreshItems(current.Value.Cache.Keys); - } - break; - } + protected override void OnItemRefreshed(TSource item, TKey key) + { + if (_reevalOnRefresh && _cache.Lookup(key) is { HasValue: true } current) + { + _tracker.RefreshItems(current.Value.Cache.Keys); } } - public void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - - public void Emit(IObserver> observer) => _tracker.EmitChanges(observer); + private void SubscribeChild(TSource item, TKey key) + { + var entry = new ChangeSetCache(Context.Serialize(_changeSetSelector(item, key).IgnoreSameReferenceUpdate())); + _cache.AddOrUpdate(entry, key); + Context.Track(key, entry.Source); + } } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs index 5ebb0b1e..7d79eb9f 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs @@ -33,7 +33,7 @@ public static IObservable> OrchestrateManyMergedList(changeSetSelector, equalityComparer)) .SubscribeSafe(observer)); - private sealed class MergedListOrchestrator : ICacheOrchestrator, IChangeSet> + private sealed class MergedListOrchestrator : CacheChangeHandlerBase, IChangeSet> where TSource : notnull where TKey : notnull where TDest : notnull @@ -42,7 +42,6 @@ private sealed class MergedListOrchestrator : ICacheOrches private readonly ChangeSetMergeTracker _tracker = new(); private readonly Func>> _changeSetSelector; private readonly IEqualityComparer? _equalityComparer; - private ICacheOrchestratorContext> _context = null!; public MergedListOrchestrator( Func>> changeSetSelector, @@ -52,41 +51,39 @@ public MergedListOrchestrator( _equalityComparer = equalityComparer; } - public void Initialize(ICacheOrchestratorContext> context) => _context = context; + public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public void OnSourceChangeSet(IChangeSet changes) + public override void Emit(IObserver> observer) => _tracker.EmitChanges(observer); + + protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); + + protected override void OnItemUpdated(TSource current, TSource previous, TKey key) { - foreach (var change in changes.ToConcreteType()) + if (_entries.TryGetValue(key, out var prior)) { - switch (change.Reason) - { - case ChangeReason.Add or ChangeReason.Update: - var entry = new ClonedListChangeSet(_context.Serialize(_changeSetSelector(change.Current, change.Key).RemoveIndex()), _equalityComparer); - if (_entries.TryGetValue(change.Key, out var previous)) - { - _entries.Remove(change.Key); - _tracker.RemoveItems(previous.List); - } - - _entries[change.Key] = entry; - _context.Track(change.Key, entry.Source); - break; + _entries.Remove(key); + _tracker.RemoveItems(prior.List); + } - case ChangeReason.Remove: - if (_entries.TryGetValue(change.Key, out var removed)) - { - _entries.Remove(change.Key); - _tracker.RemoveItems(removed.List); - } + SubscribeChild(current, key); + } - _context.Track(change.Key, null); - break; - } + protected override void OnItemRemoved(TSource item, TKey key) + { + if (_entries.TryGetValue(key, out var removed)) + { + _entries.Remove(key); + _tracker.RemoveItems(removed.List); } - } - public void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); + Context.Track(key, null); + } - public void Emit(IObserver> observer) => _tracker.EmitChanges(observer); + private void SubscribeChild(TSource item, TKey key) + { + var entry = new ClonedListChangeSet(Context.Serialize(_changeSetSelector(item, key).RemoveIndex()), _equalityComparer); + _entries[key] = entry; + Context.Track(key, entry.Source); + } } } diff --git a/src/DynamicData/Cache/Internal/Orchestrator.cs b/src/DynamicData/Cache/Internal/Orchestrator.cs index 036a835f..f119f5c4 100644 --- a/src/DynamicData/Cache/Internal/Orchestrator.cs +++ b/src/DynamicData/Cache/Internal/Orchestrator.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using System.Diagnostics; -using System.Reactive.Concurrency; using System.Reactive.Disposables; using System.Reactive.Linq; using DynamicData.Internal; @@ -95,11 +94,6 @@ public void Track(TKey key, IObservable? observable) public IObservable Serialize(IObservable observable) => observable.SynchronizeSafe(_queue); - public IDisposable ScheduleEmit(TimeSpan dueTime, IScheduler scheduler, Action? onFire = null) => - Observable.Timer(dueTime, scheduler) - .SynchronizeSafe(_queue) - .Subscribe(_ => onFire?.Invoke()); - private void SubscribeToSource(IObservable> source) => _sourceSubscription.Disposable = source .SynchronizeSafe(_queue) diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index 0cb51bc8..3ebe474c 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -22,13 +22,12 @@ public IObservable> Run() => Observabl source.OrchestrateMany(new Orchestrator(transformer, equalityComparer, comparer, errorHandler)) .SubscribeSafe(observer)); - private sealed class Orchestrator : ICacheOrchestrator, IChangeSet> + private sealed class Orchestrator : CacheChangeHandlerBase, IChangeSet> { private readonly Cache, TKey> _cache = new(); private readonly ChangeSetMergeTracker _tracker; private readonly Func>>> _transformer; private readonly Action>? _errorHandler; - private ICacheOrchestratorContext> _context = null!; public Orchestrator( Func>>> transformer, @@ -41,41 +40,39 @@ public Orchestrator( _tracker = new ChangeSetMergeTracker(() => _cache.Items, comparer, equalityComparer); } - public void Initialize(ICacheOrchestratorContext> context) => _context = context; + public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public void OnSourceChangeSet(IChangeSet changes) - { - foreach (var change in changes.ToConcreteType()) - { - switch (change.Reason) - { - case ChangeReason.Add or ChangeReason.Update: - var previous = _cache.Lookup(change.Key); - var entry = new ChangeSetCache(_context.Serialize(BuildInner(change.Current, change.Key))); - _cache.AddOrUpdate(entry, change.Key); - _context.Track(change.Key, entry.Source); - if (previous.HasValue) - { - _tracker.RemoveItems(previous.Value.Cache.KeyValues); - } - break; + public override void Emit(IObserver> observer) => _tracker.EmitChanges(observer); - case ChangeReason.Remove: - if (_cache.Lookup(change.Key) is { HasValue: true } removed) - { - _cache.Remove(change.Key); - _tracker.RemoveItems(removed.Value.Cache.KeyValues); - } + protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); - _context.Track(change.Key, null); - break; - } + 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); } } - public void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); + protected override void OnItemRemoved(TSource item, TKey key) + { + if (_cache.Lookup(key) is { HasValue: true } removed) + { + _cache.Remove(key); + _tracker.RemoveItems(removed.Value.Cache.KeyValues); + } + + Context.Track(key, null); + } - public void Emit(IObserver> observer) => _tracker.EmitChanges(observer); + private void SubscribeChild(TSource item, TKey key) + { + var entry = new ChangeSetCache(Context.Serialize(BuildInner(item, key))); + _cache.AddOrUpdate(entry, key); + Context.Track(key, entry.Source); + } private IObservable> BuildInner(TSource obj, TKey key) => _errorHandler is null ? Observable.Defer(() => _transformer(obj, key)) From 67b9653ffdb8e4f3a70577037deb87efc9078ff4 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 08:58:02 -0700 Subject: [PATCH 06/30] Refactor orchestrator to sub-queue emitter contract Capture the downstream observer (as a DeliverySubQueue) at Initialize time instead of passing it to Emit per drain. The orchestrator now calls Emitter.OnNext/OnError/OnCompleted directly under the queue gate; serialization, post-termination drop, and reentrant drain are all handled by the sub-queue. ICacheOrchestrator becomes (Initialize(context, emitter), OnSourceChangeSet, OnInner, OnDrainComplete). Emit(observer) is gone. Renamed CacheChangeHandlerBase -> OrchestratorCacheChangeBase. Eliminated from the runtime: _hasTerminated, TerminalError helper, direct calls to the raw downstream observer. Eliminated from orchestrators: _pendingSource accumulator (both AutoRefresh orchestrators), _shouldFlushBuffered + _bufferedRefreshes (BufferedOrchestrator), OnChangeSetProcessed virtual (no remaining consumers). Net diff: +106 / -281 lines across the orchestrator surface. All 2386 tests pass. --- .../Internal/OrchestrateManyFixture.cs | 15 ++- src/DynamicData/Cache/Internal/AutoRefresh.cs | 96 ++++++++----------- .../Cache/Internal/GroupOnObservable.cs | 2 +- .../Cache/Internal/ICacheOrchestrator.cs | 23 +++-- .../IntObservableCacheEx.OrchestrateMany.cs | 46 +++------ ...bservableCacheEx.OrchestrateManyChanges.cs | 11 +-- ...ObservableCacheEx.OrchestrateManyMerged.cs | 4 +- ...rvableCacheEx.OrchestrateManyMergedList.cs | 4 +- .../{Orchestrator.cs => Orchestration.cs} | 64 ++++++------- ...Base.cs => OrchestratorCacheChangeBase.cs} | 30 +++--- .../Cache/Internal/TransformManyAsync.cs | 4 +- .../Cache/Internal/TransformOnObservable.cs | 2 +- 12 files changed, 134 insertions(+), 167 deletions(-) rename src/DynamicData/Cache/Internal/{Orchestrator.cs => Orchestration.cs} (70%) rename src/DynamicData/Cache/Internal/{CacheChangeHandlerBase.cs => OrchestratorCacheChangeBase.cs} (73%) diff --git a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs index b5fa6d96..5ecc833b 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs @@ -308,6 +308,7 @@ private sealed class TestOrchestrator : ICacheOrchestrator ChildCalls = []; private ICacheOrchestratorContext _context = null!; + private IObserver> _emitter = null!; public TestOrchestrator( Func>? childFactory = null, @@ -319,7 +320,11 @@ public TestOrchestrator( _onChild = onChild; } - public void Initialize(ICacheOrchestratorContext context) => _context = context; + public void Initialize(ICacheOrchestratorContext context, IObserver> emitter) + { + _context = context; + _emitter = emitter; + } public void OnSourceChangeSet(IChangeSet changes) { @@ -346,12 +351,14 @@ public void OnInner(string child, int parentKey) _cache.AddOrUpdate(new TestItem(parentKey, child), parentKey); } - public void Emit(IObserver> observer) + public void OnDrainComplete() { - Interlocked.Increment(ref EmitCallCount); var changes = _cache.CaptureChanges(); if (changes.Count > 0) - observer.OnNext(changes); + { + Interlocked.Increment(ref EmitCallCount); + _emitter.OnNext(changes); + } } } diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index 40476ceb..ef834ff6 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -19,25 +19,33 @@ internal sealed class AutoRefresh( public IObservable> Run() => Observable.Create>(observer => { var orchestrator = buffer is { } window - ? (CacheChangeHandlerBase, IChangeSet>)new BufferedOrchestrator(reEvaluator, window, scheduler ?? GlobalConfig.DefaultScheduler) + ? (OrchestratorCacheChangeBase, IChangeSet>)new BufferedOrchestrator(reEvaluator, window, scheduler ?? GlobalConfig.DefaultScheduler) : new UnbufferedOrchestrator(reEvaluator); return source.OrchestrateMany(orchestrator).SubscribeSafe(observer); }); /// - /// Unbuffered AutoRefresh: forwards source changes verbatim and emits each refresh notification - /// individually as a single-change . Refreshes for keys the - /// source has touched within the same drain cycle are suppressed (fixes #1099: the sync - /// Add+Refresh scenario where a reevaluator fires synchronously during item subscription would - /// otherwise emit both Add and Refresh for the same key). + /// Unbuffered AutoRefresh: forwards each source changeset to the emitter immediately, then emits + /// any accumulated refresh notifications at drain end as a single coalesced changeset. Refreshes + /// for keys the source has touched within the same drain cycle are suppressed (fixes #1099: the + /// sync Add+Refresh scenario where a reevaluator fires synchronously during item subscription + /// would otherwise emit both Add and Refresh for the same key). /// private sealed class UnbufferedOrchestrator(Func> reEvaluator) - : CacheChangeHandlerBase, IChangeSet> + : OrchestratorCacheChangeBase, IChangeSet> { private readonly HashSet _sourceTouched = []; - private IChangeSet? _pendingSource; private List>? _pendingRefreshes; + 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)) @@ -48,22 +56,15 @@ public override void OnInner(Change refresh, TKey key) (_pendingRefreshes ??= []).Add(refresh); } - public override void Emit(IObserver> observer) + public override void OnDrainComplete() { _sourceTouched.Clear(); - var pending = _pendingSource; - _pendingSource = null; - if (pending is { Count: > 0 }) - { - observer.OnNext(pending); - } - var refreshes = _pendingRefreshes; _pendingRefreshes = null; if (refreshes is { Count: > 0 }) { - observer.OnNext(new ChangeSet(refreshes)); + Emitter.OnNext(new ChangeSet(refreshes)); } } @@ -82,31 +83,23 @@ protected override void OnItemRemoved(TObject item, TKey key) } protected override void OnItemRefreshed(TObject item, TKey key) => _sourceTouched.Add(key); - - protected override void OnChangeSetProcessed(IChangeSet changes) => - _pendingSource = _pendingSource is null - ? changes - : new ChangeSet(_pendingSource.Concat(changes)); } /// - /// Buffered AutoRefresh: source changes flow through immediately; refreshes are collected into a - /// time-bounded buffer and emitted as a single per window, - /// deduplicated by key within the window. The buffered stream is routed through + /// Buffered AutoRefresh: source changes flow to the emitter immediately. Refreshes are collected + /// into a time-bounded buffer and emitted as a single per + /// window, deduplicated by key within the window. The buffered stream is routed through /// so the per-window flush runs /// under the same serialization gate as source and inner events. /// - private sealed class BufferedOrchestrator : CacheChangeHandlerBase, IChangeSet>, IDisposable + private sealed class BufferedOrchestrator : OrchestratorCacheChangeBase, IChangeSet>, IDisposable { private readonly Subject> _refreshes = new(); private readonly HashSet _dedupBuffer = []; private readonly Func> _reEvaluator; private readonly TimeSpan _window; private readonly IScheduler _scheduler; - private IChangeSet? _pendingSource; - private List>? _bufferedRefreshes; private IDisposable? _bufferSubscription; - private bool _shouldFlushBuffered; public BufferedOrchestrator(Func> reEvaluator, TimeSpan window, IScheduler scheduler) { @@ -121,33 +114,23 @@ public void Dispose() _refreshes.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) { _bufferSubscription ??= Context.Serialize(_refreshes.Buffer(_window, _scheduler)).Subscribe(OnRefreshBatch); _refreshes.OnNext(refresh); } - public override void Emit(IObserver> observer) + public override void OnDrainComplete() { - var pending = _pendingSource; - _pendingSource = null; - if (pending is { Count: > 0 }) - { - observer.OnNext(pending); - } - - if (!_shouldFlushBuffered) - { - return; - } - - _shouldFlushBuffered = false; - var refreshes = _bufferedRefreshes; - _bufferedRefreshes = null; - if (refreshes is { Count: > 0 }) - { - observer.OnNext(new ChangeSet(refreshes)); - } } protected override void OnItemAdded(TObject item, TKey key) => @@ -155,11 +138,6 @@ protected override void OnItemAdded(TObject item, TKey key) => protected override void OnItemRemoved(TObject item, TKey key) => Context.Track(key, null); - protected override void OnChangeSetProcessed(IChangeSet changes) => - _pendingSource = _pendingSource is null - ? changes - : new ChangeSet(_pendingSource.Concat(changes)); - private void OnRefreshBatch(IList> batch) { if (batch.Count == 0) @@ -167,17 +145,21 @@ private void OnRefreshBatch(IList> batch) return; } - var batched = _bufferedRefreshes ??= []; + var deduped = new List>(batch.Count); foreach (var change in batch) { if (_dedupBuffer.Add(change.Key)) { - batched.Add(change); + deduped.Add(change); } } _dedupBuffer.Clear(); - _shouldFlushBuffered = true; + + if (deduped.Count > 0) + { + Emitter.OnNext(new ChangeSet(deduped)); + } } } } diff --git a/src/DynamicData/Cache/Internal/GroupOnObservable.cs b/src/DynamicData/Cache/Internal/GroupOnObservable.cs index 9428ce67..5a2219f7 100644 --- a/src/DynamicData/Cache/Internal/GroupOnObservable.cs +++ b/src/DynamicData/Cache/Internal/GroupOnObservable.cs @@ -37,5 +37,5 @@ public IObservable> Run() => } }, onInner: (value, parentKey) => grouper.AddOrUpdate(parentKey, value.GroupKey, value.Item), - emit: observer => grouper.EmitChanges(observer))); + onDrainComplete: observer => grouper.EmitChanges(observer))); } diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs index 254c1916..19667f67 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs @@ -7,13 +7,13 @@ namespace DynamicData.Cache.Internal; /// /// Orchestrator contract consumed by the OrchestrateMany primitive. Implementations hold /// per-subscription state as fields and receive their -/// once via before -/// any other method is called. +/// and downstream emitter once via +/// before any other method is called. /// /// 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 by . +/// Type delivered downstream via the emitter. internal interface ICacheOrchestrator where TSource : notnull where TKey : notnull @@ -21,10 +21,14 @@ internal interface ICacheOrchestrator { /// /// Invoked exactly once by OrchestrateMany before subscribing to the source. Implementations - /// should capture in a private field for use by subsequent method calls. + /// should capture and in private fields for + /// use by subsequent method calls. The supplied is a sub-queue of the + /// orchestrator's shared delivery queue: every OnNext/OnError/OnCompleted on + /// it is automatically serialized with source and inner notifications. /// /// The runtime context, scoped to this subscription's lifetime. - void Initialize(ICacheOrchestratorContext context); + /// The downstream observer, fronted by a serializing sub-queue. + void Initialize(ICacheOrchestratorContext context, IObserver emitter); /// /// Invoked for each source changeset. @@ -40,9 +44,10 @@ internal interface ICacheOrchestrator void OnInner(TInner value, TKey key); /// - /// Invoked once per drain cycle of the shared delivery queue to flush aggregated state to - /// the downstream . + /// 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. /// - /// The downstream observer. - void Emit(IObserver observer); + void OnDrainComplete(); } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs index 7ace2139..6189294f 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs @@ -30,71 +30,55 @@ public static IObservable OrchestrateMany - new Orchestrator(source, orchestrator).Run(); + new Orchestration(source, orchestrator).Run(); /// /// Convenience overload that wraps three lambdas into an /// and delegates to . - /// Does not expose or - /// ; operators that need either - /// must implement directly. + /// Does not expose ; operators that + /// need it must implement 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 by . + /// Type delivered downstream by . /// The keyed source changeset stream. /// Invoked for each source changeset, paired with a track callback. /// Invoked for each value emitted by a tracked inner observable, paired with its key. - /// Invoked once per drain cycle to flush the aggregated state to the observer. + /// Invoked once per drain cycle to flush the aggregated state to the emitter. /// An observable that orchestrates source and inner activity into a single result stream. public static IObservable OrchestrateMany( this IObservable> source, Action, Action?>> onSourceChangeSet, Action onInner, - Action> emit) + Action> onDrainComplete) where TSource : notnull where TKey : notnull where TInner : notnull => - source.OrchestrateMany(AsCacheOrchestrator(onSourceChangeSet, onInner, emit)); - - /// - /// Wraps three lambdas into an . - /// - /// 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. - /// Invoked for each source changeset, paired with a track callback. - /// Invoked for each value emitted by a tracked inner observable, paired with its key. - /// Invoked once per drain cycle to flush the aggregated state to the observer. - /// An orchestrator that routes the three callbacks through its . - public static ICacheOrchestrator AsCacheOrchestrator( - Action, Action?>> onSourceChangeSet, - Action onInner, - Action> emit) - where TSource : notnull - where TKey : notnull - where TInner : notnull => - new LambdaCacheOrchestrator(onSourceChangeSet, onInner, emit); + source.OrchestrateMany(new LambdaCacheOrchestrator(onSourceChangeSet, onInner, onDrainComplete)); private sealed class LambdaCacheOrchestrator( Action, Action?>> onSourceChangeSet, Action onInner, - Action> emit) + Action> onDrainComplete) : ICacheOrchestrator where TSource : notnull where TKey : notnull where TInner : notnull { private ICacheOrchestratorContext _context = null!; + private IObserver _emitter = null!; - public void Initialize(ICacheOrchestratorContext context) => _context = context; + public void Initialize(ICacheOrchestratorContext context, IObserver emitter) + { + _context = context; + _emitter = emitter; + } public void OnSourceChangeSet(IChangeSet changes) => onSourceChangeSet(changes, _context.Track); public void OnInner(TInner value, TKey key) => onInner(value, key); - public void Emit(IObserver observer) => emit(observer); + public void OnDrainComplete() => onDrainComplete(_emitter); } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs index 285cea1b..d1539c94 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs @@ -46,11 +46,8 @@ public static IObservable> OrchestrateManyChanges(innerFactory, onSourceChange, onInner)) .SubscribeSafe(observer)); - private sealed class ChangesOrchestrator( - Func> innerFactory, - Action, Change> onSourceChange, - Action, TKey, TSource, TInner> onInner) - : CacheChangeHandlerBase> + private sealed class ChangesOrchestrator(Func> innerFactory, Action, Change> onSourceChange, Action, TKey, TSource, TInner> onInner) + : OrchestratorCacheChangeBase> where TSource : notnull where TKey : notnull where TInner : notnull @@ -60,12 +57,12 @@ private sealed class ChangesOrchestrator( public override void OnInner((TSource Item, TInner Value) value, TKey key) => onInner(_cache, key, value.Item, value.Value); - public override void Emit(IObserver> observer) + public override void OnDrainComplete() { var captured = _cache.CaptureChanges(); if (captured.Count > 0) { - observer.OnNext(captured); + Emitter.OnNext(captured); } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs index 6a1a200e..e727cf4b 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs @@ -39,7 +39,7 @@ public static IObservable> OrchestrateManyMerged(changeSetSelector, equalityComparer, comparer, reevalOnRefresh)) .SubscribeSafe(observer)); - private sealed class MergedOrchestrator : CacheChangeHandlerBase, IChangeSet> + private sealed class MergedOrchestrator : OrchestratorCacheChangeBase, IChangeSet> where TSource : notnull where TKey : notnull where TDest : notnull @@ -63,7 +63,7 @@ public MergedOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void Emit(IObserver> observer) => _tracker.EmitChanges(observer); + public override void OnDrainComplete() => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs index 7d79eb9f..bb706c45 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs @@ -33,7 +33,7 @@ public static IObservable> OrchestrateManyMergedList(changeSetSelector, equalityComparer)) .SubscribeSafe(observer)); - private sealed class MergedListOrchestrator : CacheChangeHandlerBase, IChangeSet> + private sealed class MergedListOrchestrator : OrchestratorCacheChangeBase, IChangeSet> where TSource : notnull where TKey : notnull where TDest : notnull @@ -53,7 +53,7 @@ public MergedListOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void Emit(IObserver> observer) => _tracker.EmitChanges(observer); + public override void OnDrainComplete() => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/Orchestrator.cs b/src/DynamicData/Cache/Internal/Orchestration.cs similarity index 70% rename from src/DynamicData/Cache/Internal/Orchestrator.cs rename to src/DynamicData/Cache/Internal/Orchestration.cs index f119f5c4..51f9e2ad 100644 --- a/src/DynamicData/Cache/Internal/Orchestrator.cs +++ b/src/DynamicData/Cache/Internal/Orchestration.cs @@ -12,44 +12,41 @@ namespace DynamicData.Cache.Internal; /// /// Drives an against a source /// changeset. returns an that constructs a -/// fresh per-subscription on each subscribe, so all per-subscription -/// state owned by the is recreated on every subscribe. +/// fresh per-subscription on each subscribe, so all per-subscription +/// state owned by the is recreated on every subscribe. /// /// 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. -internal sealed class Orchestrator( - IObservable> source, - ICacheOrchestrator orchestrator) +internal sealed class Orchestration(IObservable> source, ICacheOrchestrator orchestrator) where TSource : notnull where TKey : notnull where TInner : notnull { - public IObservable Run() => - Observable.Create(observer => new Subscription(source, observer, orchestrator)); + public IObservable Run() => Observable.Create(observer => new OrchestratorContext(source, observer, orchestrator)); - private sealed class Subscription : ICacheOrchestratorContext, IDisposable + private sealed class OrchestratorContext : ICacheOrchestratorContext, IDisposable { private readonly KeyedDisposable _innerSubscriptions = new(); private readonly SingleAssignmentDisposable _sourceSubscription = new(); private readonly SharedDeliveryQueue _queue; - private readonly IObserver _observer; + private readonly DeliverySubQueue _emitter; private readonly ICacheOrchestrator _orchestrator; private int _subscriptionCounter = 1; private bool _isCompleted; - private bool _hasTerminated; private bool _disposed; - public Subscription( - IObservable> source, - IObserver observer, - ICacheOrchestrator orchestrator) + public OrchestratorContext(IObservable> source, IObserver observer, ICacheOrchestrator orchestrator) { - _observer = observer; _orchestrator = orchestrator; _queue = new SharedDeliveryQueue(onDrainComplete: OnDrainComplete); - _orchestrator.Initialize(this); + + // Create the emitter sub-queue first (lowest index, drains last LIFO) so source-triggered + // sync inner emissions (higher index) deliver first and any orchestrator emit lands on the + // emitter after that work has settled. + _emitter = _queue.CreateQueue(observer); + _orchestrator.Initialize(this, _emitter); SubscribeToSource(source); } @@ -64,6 +61,7 @@ public void Dispose() _queue.Dispose(); _sourceSubscription.Dispose(); _innerSubscriptions.Dispose(); + _emitter.Dispose(); } public void Track(TKey key, IObservable? observable) @@ -88,7 +86,7 @@ public void Track(TKey key, IObservable? observable) .Finally(DecrementSubscriptionCount) .SubscribeSafe( onNext: value => OnInner(value, key), - onError: TerminalError, + onError: _emitter.OnError, onCompleted: () => _innerSubscriptions.Remove(key)); } @@ -99,7 +97,7 @@ private void SubscribeToSource(IObservable> source) => .SynchronizeSafe(_queue) .SubscribeSafe( onNext: OnSourceChangeSet, - onError: TerminalError, + onError: _emitter.OnError, onCompleted: DecrementSubscriptionCount); private void OnSourceChangeSet(IChangeSet changes) @@ -110,7 +108,7 @@ private void OnSourceChangeSet(IChangeSet changes) } catch (Exception error) { - TerminalError(error); + _emitter.OnError(error); } } @@ -122,35 +120,29 @@ private void OnInner(TInner value, TKey key) } catch (Exception error) { - TerminalError(error); + _emitter.OnError(error); } } private void OnDrainComplete() { - if (_hasTerminated) + try { - return; + _orchestrator.OnDrainComplete(); } - - _orchestrator.Emit(_observer); - - if (Volatile.Read(ref _isCompleted) && !_hasTerminated) + catch (Exception error) { - _hasTerminated = true; - _observer.OnCompleted(); + _emitter.OnError(error); + return; } - } - private void TerminalError(Exception error) - { - if (_hasTerminated) + if (Volatile.Read(ref _isCompleted)) { - return; + // Latch off so the inevitable reentrant drain (triggered by enqueueing OnCompleted on + // the emitter sub-queue) doesn't try to complete the stream twice. + Volatile.Write(ref _isCompleted, false); + _emitter.OnCompleted(); } - - _hasTerminated = true; - _observer.OnError(error); } private void DecrementSubscriptionCount() diff --git a/src/DynamicData/Cache/Internal/CacheChangeHandlerBase.cs b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs similarity index 73% rename from src/DynamicData/Cache/Internal/CacheChangeHandlerBase.cs rename to src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs index 7335fd66..e07888f5 100644 --- a/src/DynamicData/Cache/Internal/CacheChangeHandlerBase.cs +++ b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs @@ -8,27 +8,33 @@ namespace DynamicData.Cache.Internal; /// Optional base for implementations /// that prefer per- virtual hooks over decoding the changeset themselves. /// The base implementation of walks the changeset and dispatches each -/// change to the corresponding protected virtual method, then invokes -/// for end-of-changeset bookkeeping. Captures the -/// in a property accessible to derived classes. +/// change to the corresponding protected virtual method. Captures the +/// and downstream emitter in +/// and properties accessible to derived classes. /// /// 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 by . -internal abstract class CacheChangeHandlerBase - : ICacheOrchestrator +/// Type delivered downstream via the emitter. +internal abstract class OrchestratorCacheChangeBase : ICacheOrchestrator where TSource : notnull where TKey : notnull where TInner : notnull { private ICacheOrchestratorContext _context = null!; + private IObserver _emitter = null!; protected ICacheOrchestratorContext Context => _context; - public void Initialize(ICacheOrchestratorContext context) => _context = context; + protected IObserver Emitter => _emitter; - public void OnSourceChangeSet(IChangeSet changes) + public void Initialize(ICacheOrchestratorContext context, IObserver emitter) + { + _context = context; + _emitter = emitter; + } + + public virtual void OnSourceChangeSet(IChangeSet changes) { foreach (var change in changes.ToConcreteType()) { @@ -51,13 +57,11 @@ public void OnSourceChangeSet(IChangeSet changes) break; } } - - OnChangeSetProcessed(changes); } public abstract void OnInner(TInner value, TKey key); - public abstract void Emit(IObserver observer); + public abstract void OnDrainComplete(); protected virtual void OnItemAdded(TSource item, TKey key) { @@ -72,8 +76,4 @@ protected virtual void OnItemRemoved(TSource item, TKey key) protected virtual void OnItemRefreshed(TSource item, TKey key) { } - - protected virtual void OnChangeSetProcessed(IChangeSet changes) - { - } } diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index 3ebe474c..b099f662 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -22,7 +22,7 @@ public IObservable> Run() => Observabl source.OrchestrateMany(new Orchestrator(transformer, equalityComparer, comparer, errorHandler)) .SubscribeSafe(observer)); - private sealed class Orchestrator : CacheChangeHandlerBase, IChangeSet> + private sealed class Orchestrator : OrchestratorCacheChangeBase, IChangeSet> { private readonly Cache, TKey> _cache = new(); private readonly ChangeSetMergeTracker _tracker; @@ -42,7 +42,7 @@ public Orchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void Emit(IObserver> observer) => _tracker.EmitChanges(observer); + public override void OnDrainComplete() => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/TransformOnObservable.cs b/src/DynamicData/Cache/Internal/TransformOnObservable.cs index c7f32438..d6d9af07 100644 --- a/src/DynamicData/Cache/Internal/TransformOnObservable.cs +++ b/src/DynamicData/Cache/Internal/TransformOnObservable.cs @@ -48,7 +48,7 @@ public IObservable> Run() => Observable.Defer(() } }, onInner: (value, key) => cache.AddOrUpdate(value, key), - emit: observer => + onDrainComplete: observer => { var captured = cache.CaptureChanges(); if (captured.Count > 0) From fb471d7709542221a59a4b8ac3ad83f6541cf52d Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 08:58:21 -0700 Subject: [PATCH 07/30] FilterOnObservable: switch buffered path to leading-edge timer Replace the quiescence-based throttle+amb scheme with a simpler leading-edge buffer: Publish + Buffer(() => Take(1).Delay(window, scheduler)) routed through the existing public FlattenBufferResult helper. Behaviour: idle streams allocate nothing (no timer scheduled until an actual emission arrives). Bursts still emit at most every 'window' interval. The throttle-based settle window is gone; if a caller relied on early flushing during quiet periods, they will now wait the full window. Net wire-output is the same set of merged changesets, just at slightly different timing. --- .../Cache/Internal/FilterOnObservable.cs | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/DynamicData/Cache/Internal/FilterOnObservable.cs b/src/DynamicData/Cache/Internal/FilterOnObservable.cs index 3bf2b406..186e5696 100644 --- a/src/DynamicData/Cache/Internal/FilterOnObservable.cs +++ b/src/DynamicData/Cache/Internal/FilterOnObservable.cs @@ -2,7 +2,6 @@ // Roland Pheasant licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Reactive; using System.Reactive.Concurrency; using System.Reactive.Linq; using DynamicData.Internal; @@ -46,20 +45,8 @@ public IObservable> Run() => Observable.Defer(() => if (buffer is { } window) { var sched = scheduler ?? GlobalConfig.DefaultScheduler; - var quiet = TimeSpan.FromTicks(window.Ticks / 2); - - // Quiescence-based buffering with a hard latency cap: - // - Throttle(window/2): close the buffer after window/2 of source quiet (let bursts settle) - // - Timer(window): cap at the full window so sustained streams cannot starve the boundary - // - Amb picks whichever fires first - // Single-changeset windows are forwarded as-is to avoid an extra ChangeSet allocation. - changes = changes.Publish(published => published.Buffer(() => - published.Throttle(quiet, sched).Select(static _ => Unit.Default) - .Amb(Observable.Timer(window, sched).Select(static _ => Unit.Default)))) - .Where(static batches => batches.Count > 0) - .Select(static batches => batches.Count == 1 - ? batches[0] - : new ChangeSet(batches.SelectMany(static cs => cs))); + changes = changes.Publish(published => published.Buffer(() => published.Take(1).Delay(window, sched))) + .FlattenBufferResult(); } return changes; From 1ec5e4c9daffe89ae5f774c479486a6a3eb7a7ac Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 09:31:38 -0700 Subject: [PATCH 08/30] Refactor AutoRefresh buffering logic Modified the buffering logic in the `OnInner` method of the `AutoRefresh` class. Changed from using `_refreshes.Buffer(_window, _scheduler)` to `_refreshes.Buffer(() => _refreshes.Take(1).Delay(_window, _scheduler))`. This update introduces a dynamic window for buffering events, potentially improving the timing and responsiveness of the refresh batching mechanism. --- src/DynamicData/Cache/Internal/AutoRefresh.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index ef834ff6..b37af942 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -125,7 +125,10 @@ public override void OnSourceChangeSet(IChangeSet changes) public override void OnInner(Change refresh, TKey key) { - _bufferSubscription ??= Context.Serialize(_refreshes.Buffer(_window, _scheduler)).Subscribe(OnRefreshBatch); + _bufferSubscription ??= Context + .Serialize(_refreshes.Buffer(() => _refreshes.Take(1).Delay(_window, _scheduler))) + .Subscribe(OnRefreshBatch); + _refreshes.OnNext(refresh); } From 058754e3953fa8d6e774cc9ffe97423961da9db5 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 10:31:40 -0700 Subject: [PATCH 09/30] AutoRefresh: unify buffered/unbuffered, fix stale-refresh hazard Collapse UnbufferedOrchestrator and BufferedOrchestrator into a single Orchestrator. Source changes still flow to the emitter immediately in both modes; refreshes accumulate in a per-key dictionary (latest value wins) and flush either at drain end (no buffer) or via a SerialDisposable-wrapped Timer armed by the first pending refresh (buffered). Pending refreshes are dropped when the source Updates or Removes the same key, so a Refresh carrying a value the source has already superseded is never emitted. The #1099 source-touched suppression now applies to the buffered path as well, removing the sibling-bug between the two modes. Orchestration runtime now disposes orchestrators that implement IDisposable, fixing a pre-existing leak where the buffered orchestrator's Subject and buffer subscription survived subscription teardown. Adds 6 regression tests in AutoRefreshOnObservableFixture covering each scenario (Remove drops pending refresh, Update replaces it, multi-Update keeps only the latest) for both buffer modes. --- .../AutoRefreshOnObservableFixture.Base.cs | 266 +++++++++++++++++- src/DynamicData/Cache/Internal/AutoRefresh.cs | 131 +++------ .../Cache/Internal/Orchestration.cs | 1 + 3 files changed, 311 insertions(+), 87 deletions(-) diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs index c3b1fd1d..a5b6953d 100644 --- a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs @@ -125,7 +125,271 @@ 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 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() { diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index b37af942..4fbc60b3 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for full license information. using System.Reactive.Concurrency; +using System.Reactive.Disposables; using System.Reactive.Linq; -using System.Reactive.Subjects; namespace DynamicData.Cache.Internal; @@ -18,24 +18,31 @@ internal sealed class AutoRefresh( { public IObservable> Run() => Observable.Create>(observer => { - var orchestrator = buffer is { } window - ? (OrchestratorCacheChangeBase, IChangeSet>)new BufferedOrchestrator(reEvaluator, window, scheduler ?? GlobalConfig.DefaultScheduler) - : new UnbufferedOrchestrator(reEvaluator); + var orchestrator = new Orchestrator(reEvaluator, buffer, buffer is null ? null : scheduler ?? GlobalConfig.DefaultScheduler); return source.OrchestrateMany(orchestrator).SubscribeSafe(observer); }); /// - /// Unbuffered AutoRefresh: forwards each source changeset to the emitter immediately, then emits - /// any accumulated refresh notifications at drain end as a single coalesced changeset. Refreshes - /// for keys the source has touched within the same drain cycle are suppressed (fixes #1099: the - /// sync Add+Refresh scenario where a reevaluator fires synchronously during item subscription - /// would otherwise emit both Add and Refresh for the same key). + /// Forwards each source changeset to the emitter immediately. Refresh notifications from + /// per-key reevaluators are accumulated in a dictionary (latest value wins) and flushed at + /// drain end when is , otherwise via a + /// single-shot Timer armed by the first pending refresh. Update and Remove on the source drop + /// any pending refresh for that key, so a Refresh whose value has been obsoleted by a later + /// source event is never emitted. Refreshes for keys the source has touched within the same + /// drain cycle are suppressed, so a reevaluator that fires synchronously during item + /// subscription does not produce a redundant Refresh paired with the Add. /// - private sealed class UnbufferedOrchestrator(Func> reEvaluator) - : OrchestratorCacheChangeBase, IChangeSet> + private sealed class Orchestrator( + Func> reEvaluator, + TimeSpan? buffer, + IScheduler? scheduler) + : OrchestratorCacheChangeBase, IChangeSet>, IDisposable { + private readonly Dictionary> _pendingRefreshes = new(); private readonly HashSet _sourceTouched = []; - private List>? _pendingRefreshes; + private readonly SerialDisposable _timerSubscription = new(); + + public void Dispose() => _timerSubscription.Dispose(); public override void OnSourceChangeSet(IChangeSet changes) { @@ -53,18 +60,24 @@ public override void OnInner(Change refresh, TKey key) return; } - (_pendingRefreshes ??= []).Add(refresh); + _pendingRefreshes[key] = refresh; + + if (buffer is { } window) + { + // Arm a single-shot Timer if no window is currently running. Serialize routes the + // tick back through the orchestrator's gate so FlushPending runs under the same + // lock as source/inner events. + _timerSubscription.Disposable ??= Context.Serialize(Observable.Timer(window, scheduler!)).Subscribe(_ => FlushPending()); + } } public override void OnDrainComplete() { _sourceTouched.Clear(); - var refreshes = _pendingRefreshes; - _pendingRefreshes = null; - if (refreshes is { Count: > 0 }) + if (buffer is null) { - Emitter.OnNext(new ChangeSet(refreshes)); + FlushPending(); } } @@ -74,95 +87,41 @@ protected override void OnItemAdded(TObject item, TKey key) Context.Track(key, reEvaluator(item, key).Select(_ => new Change(ChangeReason.Refresh, key, item))); } - protected override void OnItemUpdated(TObject current, TObject previous, TKey key) => OnItemAdded(current, key); + 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.Track(key, null); } protected override void OnItemRefreshed(TObject item, TKey key) => _sourceTouched.Add(key); - } - - /// - /// Buffered AutoRefresh: source changes flow to the emitter immediately. Refreshes are collected - /// into a time-bounded buffer and emitted as a single per - /// window, deduplicated by key within the window. The buffered stream is routed through - /// so the per-window flush runs - /// under the same serialization gate as source and inner events. - /// - private sealed class BufferedOrchestrator : OrchestratorCacheChangeBase, IChangeSet>, IDisposable - { - private readonly Subject> _refreshes = new(); - private readonly HashSet _dedupBuffer = []; - private readonly Func> _reEvaluator; - private readonly TimeSpan _window; - private readonly IScheduler _scheduler; - private IDisposable? _bufferSubscription; - - public BufferedOrchestrator(Func> reEvaluator, TimeSpan window, IScheduler scheduler) - { - _reEvaluator = reEvaluator; - _window = window; - _scheduler = scheduler; - } - public void Dispose() + private void DropPending(TKey key) { - _bufferSubscription?.Dispose(); - _refreshes.Dispose(); - } - - public override void OnSourceChangeSet(IChangeSet changes) - { - base.OnSourceChangeSet(changes); - if (changes.Count > 0) + if (_pendingRefreshes.Remove(key) && _pendingRefreshes.Count == 0) { - Emitter.OnNext(changes); + _timerSubscription.Disposable = null; } } - public override void OnInner(Change refresh, TKey key) + private void FlushPending() { - _bufferSubscription ??= Context - .Serialize(_refreshes.Buffer(() => _refreshes.Take(1).Delay(_window, _scheduler))) - .Subscribe(OnRefreshBatch); - - _refreshes.OnNext(refresh); - } - - public override void OnDrainComplete() - { - } + _timerSubscription.Disposable = null; - protected override void OnItemAdded(TObject item, TKey key) => - Context.Track(key, _reEvaluator(item, key).Select(_ => new Change(ChangeReason.Refresh, key, item))); - - protected override void OnItemRemoved(TObject item, TKey key) => Context.Track(key, null); - - private void OnRefreshBatch(IList> batch) - { - if (batch.Count == 0) + if (_pendingRefreshes.Count == 0) { return; } - var deduped = new List>(batch.Count); - foreach (var change in batch) - { - if (_dedupBuffer.Add(change.Key)) - { - deduped.Add(change); - } - } - - _dedupBuffer.Clear(); - - if (deduped.Count > 0) - { - Emitter.OnNext(new ChangeSet(deduped)); - } + var batch = new ChangeSet(_pendingRefreshes.Values); + _pendingRefreshes.Clear(); + Emitter.OnNext(batch); } } } diff --git a/src/DynamicData/Cache/Internal/Orchestration.cs b/src/DynamicData/Cache/Internal/Orchestration.cs index 51f9e2ad..7331a2ac 100644 --- a/src/DynamicData/Cache/Internal/Orchestration.cs +++ b/src/DynamicData/Cache/Internal/Orchestration.cs @@ -62,6 +62,7 @@ public void Dispose() _sourceSubscription.Dispose(); _innerSubscriptions.Dispose(); _emitter.Dispose(); + (_orchestrator as IDisposable)?.Dispose(); } public void Track(TKey key, IObservable? observable) From b2fe104e8f2a93a73818bd446ea5039e2ae2e014 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 10:56:42 -0700 Subject: [PATCH 10/30] AutoRefresh: add buffered Update-then-quiet regression test Asserts that when an Update lands inside an armed window and the new instance's reevaluator never emits, no Refresh ever surfaces. Pins the 'pending empty disarms the timer' branch in isolation, separate from the existing test that follows up with a v2 emission. --- .../AutoRefreshOnObservableFixture.Base.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs index a5b6953d..fb6bb000 100644 --- a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs @@ -293,6 +293,52 @@ public void ChangeSetBufferIsGiven_MultipleUpdatesDuringWindow_OnlyLatestRefresh results.HasCompleted.Should().BeFalse("the source has not completed"); } + [Fact] + public void ChangeSetBufferIsGiven_UpdateBeforeWindowExpires_NoRefreshIfNewInstanceStaysQuiet() + { + // 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, arming a window for T=15) + scheduler.AdvanceTo(TimeSpan.FromSeconds(5).Ticks); + ++item1V1.Value; + + + // UUT Action (Update to v2 at T=8, within the v1-armed window; v2 stays quiet thereafter) + scheduler.AdvanceTo(TimeSpan.FromSeconds(8).Ticks); + source.AddOrUpdate(item1V2); + + + // UUT Action (advance well past the original v1 window boundary) + scheduler.AdvanceTo(TimeSpan.FromSeconds(30).Ticks); + + results.Error.Should().BeNull(); + results.RecordedChangeSets.SelectMany(static cs => cs).Where(static c => c.Reason is ChangeReason.Refresh).Should().BeEmpty( + "no Refresh has any source to flush once Update drops the pending v1 entry and v2 never emits"); + results.RecordedChangeSets.Count.Should().Be(2, "only the initial Add and the Update propagated"); + results.HasCompleted.Should().BeFalse("the source has not completed"); + } + [Fact] public void NoChangeSetBuffer_AddAndRemoveInSameChangeset_NoRefreshEmitted() { From da3ac61b31a2008e010d5817e447b523fdb71bb6 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 10:58:09 -0700 Subject: [PATCH 11/30] Revert redundant Update-then-quiet test The mid-test assertion in ChangeSetBufferIsGiven_UpdateDuringWindow_RefreshEmittedForNewInstance already proves the v1-armed timer is disarmed by the Update (asserts BeEmpty at the original v1 window boundary). A disarmed timer cannot spontaneously fire later, so the separate quiet-after-update case is transitively covered. --- .../AutoRefreshOnObservableFixture.Base.cs | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs index fb6bb000..a5b6953d 100644 --- a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs @@ -293,52 +293,6 @@ public void ChangeSetBufferIsGiven_MultipleUpdatesDuringWindow_OnlyLatestRefresh results.HasCompleted.Should().BeFalse("the source has not completed"); } - [Fact] - public void ChangeSetBufferIsGiven_UpdateBeforeWindowExpires_NoRefreshIfNewInstanceStaysQuiet() - { - // 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, arming a window for T=15) - scheduler.AdvanceTo(TimeSpan.FromSeconds(5).Ticks); - ++item1V1.Value; - - - // UUT Action (Update to v2 at T=8, within the v1-armed window; v2 stays quiet thereafter) - scheduler.AdvanceTo(TimeSpan.FromSeconds(8).Ticks); - source.AddOrUpdate(item1V2); - - - // UUT Action (advance well past the original v1 window boundary) - scheduler.AdvanceTo(TimeSpan.FromSeconds(30).Ticks); - - results.Error.Should().BeNull(); - results.RecordedChangeSets.SelectMany(static cs => cs).Where(static c => c.Reason is ChangeReason.Refresh).Should().BeEmpty( - "no Refresh has any source to flush once Update drops the pending v1 entry and v2 never emits"); - results.RecordedChangeSets.Count.Should().Be(2, "only the initial Add and the Update propagated"); - results.HasCompleted.Should().BeFalse("the source has not completed"); - } - [Fact] public void NoChangeSetBuffer_AddAndRemoveInSameChangeset_NoRefreshEmitted() { From d24f10291d5e5817462af451b20ba0aca03373dd Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 12:04:21 -0700 Subject: [PATCH 12/30] Migrate MergeMany and MergeManyItems to orchestrator Both operators were hand-rolling the per-key inner subscription, completion counter, and queue plumbing that the Orchestrator runtime already provides. The orchestrator version eliminates the StrongBox counter, the Concat(Observable.Never) hack to suppress source completion, and the manual Finally(CheckCompleted) bookkeeping on each inner subscription. Inner-observable errors now terminate the merged stream, matching the standard Rx contract. The previous behavior swallowed inner errors and treated them as completion. Two tests that asserted the swallow are updated to assert the propagation contract; consumers that want the old behavior can wrap their selector output with .Catch(Observable.Empty<...>()). OrchestratorCacheChangeBase.OnDrainComplete is now virtual with an empty default. Orchestrators that have no per-drain coalescing work no longer need to override it. --- .../Cache/MergeManyWithKeyOverloadFixture.cs | 24 +++++------ src/DynamicData/Cache/Internal/MergeMany.cs | 41 +++++-------------- .../Cache/Internal/MergeManyItems.cs | 21 +++++++--- .../Internal/OrchestratorCacheChangeBase.cs | 4 +- 4 files changed, 39 insertions(+), 51 deletions(-) diff --git a/src/DynamicData.Tests/Cache/MergeManyWithKeyOverloadFixture.cs b/src/DynamicData.Tests/Cache/MergeManyWithKeyOverloadFixture.cs index e6987e84..e307a9dd 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/Cache/Internal/MergeMany.cs b/src/DynamicData/Cache/Internal/MergeMany.cs index fe2eecc7..653550b1 100644 --- a/src/DynamicData/Cache/Internal/MergeMany.cs +++ b/src/DynamicData/Cache/Internal/MergeMany.cs @@ -1,10 +1,8 @@ -// 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; @@ -13,7 +11,6 @@ internal sealed class MergeMany where TKey : notnull { private readonly Func> _observableSelector; - private readonly IObservable> _source; public MergeMany(IObservable> source, Func> observableSelector) @@ -30,32 +27,16 @@ public MergeMany(IObservable> source, Func observableSelector(t); } - public IObservable Run() => Observable.Create( - observer => - { - 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) => - { - 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) + public IObservable Run() => Observable.Create(observer => + _source.OrchestrateMany(new Orchestrator(_observableSelector)).SubscribeSafe(observer)); + + private sealed class Orchestrator(Func> selector) + : OrchestratorCacheChangeBase { - if (Interlocked.Decrement(ref counter.Value) == 0 && !queue.IsTerminated) - { - queue.OnCompleted(); - } + public override void OnInner(TDestination value, TKey key) => Emitter.OnNext(value); + + protected override void OnItemAdded(TObject item, TKey key) => Context.Track(key, selector(item, key)); + + protected override void OnItemRemoved(TObject item, TKey key) => Context.Track(key, null); } } diff --git a/src/DynamicData/Cache/Internal/MergeManyItems.cs b/src/DynamicData/Cache/Internal/MergeManyItems.cs index 26b85614..cd10c8f5 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,24 @@ 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() => Observable.Create>(observer => + _source.OrchestrateMany(new Orchestrator(_observableSelector)).SubscribeSafe(observer)); + + private sealed class Orchestrator(Func> selector) + : OrchestratorCacheChangeBase> + { + public override void OnInner((TObject Item, TDestination Value) value, TKey key) => + Emitter.OnNext(new ItemWithValue(value.Item, value.Value)); + + protected override void OnItemAdded(TObject item, TKey key) => + Context.Track(key, selector(item, key).Select(value => (Item: item, Value: value))); + + protected override void OnItemRemoved(TObject item, TKey key) => Context.Track(key, null); + } } diff --git a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs index e07888f5..995f6e81 100644 --- a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs +++ b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs @@ -61,7 +61,9 @@ public virtual void OnSourceChangeSet(IChangeSet changes) public abstract void OnInner(TInner value, TKey key); - public abstract void OnDrainComplete(); + public virtual void OnDrainComplete() + { + } protected virtual void OnItemAdded(TSource item, TKey key) { From b244998c747f77bc44d0972a1742731c54a426db Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 15:12:57 -0700 Subject: [PATCH 13/30] Fix three correctness bugs in orchestrator runtime C1 (CRITICAL): TransformManyAsync and other aggregating orchestrators intermittently dropped concurrent inner emissions. When OnDrainComplete called tracker.EmitChanges(Emitter), the resulting Emitter.OnNext triggered a reentrant DrainPending that delivered the emitter item AND drained any concurrent inner items that had arrived during the brief lock release in DeliverStaged. Those items added to the tracker via OnInner but were never flushed: the outer DrainAll loop saw active bits empty and exited without firing _onDrainComplete again. Fix: ChangeSetMergeTracker.EmitChanges (and the List variant + DynamicGrouper) now return bool indicating whether they emitted. Every aggregating orchestrator loops in OnDrainComplete until the tracker reports nothing left to emit. C2 (CRITICAL): Buffered AutoRefresh dropped pending refreshes when the source completed before the buffer timer fired. The Timer subscription was registered via Context.Serialize, which does not participate in the orchestrator's completion counter; source completion could race ahead and fire Emitter.OnCompleted while a refresh sat in the pending dictionary unflushed. Fix: added ICacheOrchestratorContext.TrackAuxiliary(observable, onNext) that subscribes through the queue AND contributes to completion accounting via Finally(DecrementSubscriptionCount). AutoRefresh.Orchestrator's buffered timer now uses TrackAuxiliary, so completion waits for the timer to fire and flush any pending refresh. H1 (HIGH, latent): The 3-lambda OrchestrateMany overload constructed a single LambdaCacheOrchestrator eagerly. Each subscription to the returned observable called Initialize on the same orchestrator instance, overwriting _context and _emitter from earlier subscribers. Current consumers wrapped the call site in Observable.Defer/Create so the bug was latent, but the primitive was a trap. Fix: wrapped the lambda overload in Observable.Defer so each subscription constructs a fresh orchestrator. Adds three regression tests covering the reentrant-drain hazard, the multi-subscribe trap, and the buffered completion-flush case. Full suite passes 2402/2402; the previously-flaky TransformManyAsyncFixture test now passes 30/30. --- .../AutoRefreshOnObservableFixture.Base.cs | 46 ++++++++ .../Internal/OrchestrateManyFixture.cs | 107 ++++++++++++++++++ src/DynamicData/Cache/Internal/AutoRefresh.cs | 14 ++- .../Cache/Internal/ChangeSetMergeTracker.cs | 6 +- .../Cache/Internal/DynamicGrouper.cs | 5 +- .../Cache/Internal/GroupOnObservable.cs | 7 +- .../Internal/ICacheOrchestratorContext.cs | 13 +++ .../IntObservableCacheEx.OrchestrateMany.cs | 7 +- ...bservableCacheEx.OrchestrateManyChanges.cs | 9 +- ...ObservableCacheEx.OrchestrateManyMerged.cs | 11 +- ...rvableCacheEx.OrchestrateManyMergedList.cs | 7 +- .../Cache/Internal/Orchestration.cs | 16 +++ .../Cache/Internal/TransformManyAsync.cs | 7 +- .../Cache/Internal/TransformOnObservable.cs | 9 +- .../List/Internal/ChangeSetMergeTracker.cs | 5 +- 15 files changed, 252 insertions(+), 17 deletions(-) diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs index a5b6953d..22575fa2 100644 --- a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs @@ -293,6 +293,52 @@ public void ChangeSetBufferIsGiven_MultipleUpdatesDuringWindow_OnlyLatestRefresh 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.HasCompleted.Should().BeFalse( + "completion must wait for the in-flight buffer window to flush its pending refresh"); + + + // UUT Action (advance the scheduler to the original window boundary) + scheduler.AdvanceTo(TimeSpan.FromSeconds(15).Ticks); + + 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( + "all upstream subscriptions and the buffer window have completed"); + } + [Fact] public void NoChangeSetBuffer_AddAndRemoveInSameChangeset_NoRefreshEmitted() { diff --git a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs index 5ecc833b..2ebd4123 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs @@ -272,6 +272,113 @@ public async Task DeadlockProof_CrossFeedingSubscriptions() "cross-feeding OrchestrateMany subscriptions should not deadlock"); } + /// + /// Concurrent source/inner emissions during the orchestrator's per-drain emit must not be + /// lost: items delivered via the reentrant drain inside Emitter.OnNext settle into the + /// orchestrator's state and must be flushed before drain exits, otherwise downstream + /// observers miss them. + /// + [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); + + // Per-source-item inner subject so each producer task has a stable inner stream to push into. + var innerSubjects = new Dictionary>(); + for (var i = 1; i <= producerCount; i++) + { + innerSubjects[i] = new Subject(); + } + + var orchestrator = new TestOrchestrator(key => innerSubjects[key]); + var observer = new TestObserver(); + using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + + // Add a source item per producer to subscribe each inner subject. + for (var i = 1; i <= producerCount; i++) + { + source.AddOrUpdate(new TestItem(i, "init")); + } + + // Reset child-call tracking captured during init so we count only the burst below. + 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); + + // Drain may finish on a producer thread after WhenAll observes the task completing, so + // settle briefly to let any in-flight delivery finish. + await Task.Delay(50); + + 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(); + } + } + + /// + /// The lambda overload of OrchestrateMany must build a fresh orchestrator per subscription; + /// the orchestrator holds mutable per-subscription state and reuse across subscribers corrupts + /// the first subscriber's context. + /// + [Fact] + public void LambdaOverload_MultipleSubscriptions_DoNotShareOrchestrator() + { + using var source = new SourceCache(x => x.Key); + + var emitCalls = 0; + var contexts = new List(); + + // Build a chain that captures whichever context (via the track callback's identity) the + // orchestrator received in Initialize. Two subscribers should each see their own context. + var observable = source.Connect().OrchestrateMany( + onSourceChangeSet: (changes, track) => + { + // Capture an identity-stable token for the track delegate, proving each subscription + // has its own context. Hash code of the bound delegate target reflects the underlying + // OrchestratorContext instance. + lock (contexts) + { + contexts.Add(System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(track.Target!)); + } + }, + 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"); + } + } + // ═══════════════════════════════════════════════════════════════ // Test Infrastructure // ═══════════════════════════════════════════════════════════════ diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index 4fbc60b3..e05ec847 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -64,10 +64,9 @@ public override void OnInner(Change refresh, TKey key) if (buffer is { } window) { - // Arm a single-shot Timer if no window is currently running. Serialize routes the - // tick back through the orchestrator's gate so FlushPending runs under the same - // lock as source/inner events. - _timerSubscription.Disposable ??= Context.Serialize(Observable.Timer(window, scheduler!)).Subscribe(_ => FlushPending()); + // TrackAuxiliary contributes to completion accounting so source/inner completion + // cannot terminate the stream while a buffered refresh is still pending. + _timerSubscription.Disposable ??= Context.TrackAuxiliary(Observable.Timer(window, scheduler!), _ => FlushPending()); } } @@ -77,7 +76,12 @@ public override void OnDrainComplete() if (buffer is null) { - FlushPending(); + // Loop until pending is empty: each Emitter.OnNext triggers a reentrant drain that + // may add new refreshes via OnInner before the orchestrator regains control. + while (_pendingRefreshes.Count > 0) + { + FlushPending(); + } } } diff --git a/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs b/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs index ae973094..1793feac 100644 --- a/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs +++ b/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs @@ -100,18 +100,22 @@ public void ProcessChangeSet(IChangeSet changes, IObserver> observer) + public bool EmitChanges(IObserver> observer) { var changeSet = _resultCache.CaptureChanges(); + var emitted = false; if (changeSet.Count != 0) { observer.OnNext(changeSet); + emitted = true; } if (_hasCompleted) { observer.OnCompleted(); } + + return emitted; } private void OnItemAdded(TObject item, TKey key) diff --git a/src/DynamicData/Cache/Internal/DynamicGrouper.cs b/src/DynamicData/Cache/Internal/DynamicGrouper.cs index cb488019..11c2d359 100644 --- a/src/DynamicData/Cache/Internal/DynamicGrouper.cs +++ b/src/DynamicData/Cache/Internal/DynamicGrouper.cs @@ -143,7 +143,7 @@ public void Initialize(IEnumerable> initialValues, F EmitChanges(observer); } - public void EmitChanges(IObserver> observer) + public bool EmitChanges(IObserver> observer) { // Verify logic doesn't capture any non-empty groups Debug.Assert(_emptyGroups.All(static group => group.Cache.Count == 0), "Non empty Group in Empty Group HashSet"); @@ -170,7 +170,10 @@ public void EmitChanges(IObserver> obs if (changeSet.Count != 0) { observer.OnNext(new GroupChangeSet(changeSet)); + return true; } + + return false; } public void Dispose() diff --git a/src/DynamicData/Cache/Internal/GroupOnObservable.cs b/src/DynamicData/Cache/Internal/GroupOnObservable.cs index 5a2219f7..84d75be2 100644 --- a/src/DynamicData/Cache/Internal/GroupOnObservable.cs +++ b/src/DynamicData/Cache/Internal/GroupOnObservable.cs @@ -37,5 +37,10 @@ public IObservable> Run() => } }, onInner: (value, parentKey) => grouper.AddOrUpdate(parentKey, value.GroupKey, value.Item), - onDrainComplete: observer => grouper.EmitChanges(observer))); + onDrainComplete: observer => + { + while (grouper.EmitChanges(observer)) + { + } + })); } diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs index 4cefd1fb..6a8bce6d 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs @@ -35,4 +35,17 @@ internal interface ICacheOrchestratorContext /// The observable to wrap. /// A new observable whose OnNext delivery runs under the orchestrator's queue lock. IObservable Serialize(IObservable observable); + + /// + /// Subscribes to an auxiliary observable whose lifetime contributes to the orchestrator's + /// completion accounting. The downstream stream cannot complete until every auxiliary + /// subscription either completes or is disposed. Emissions are routed through the shared + /// delivery queue and dispatched to ; errors propagate to the + /// downstream emitter. + /// + /// Value type emitted by the auxiliary observable. + /// The auxiliary observable. + /// Callback invoked under the queue lock for each emission. + /// A disposable that cancels the subscription. Disposing decrements completion accounting. + IDisposable TrackAuxiliary(IObservable observable, Action onNext); } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs index 6189294f..ba237218 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs @@ -2,6 +2,8 @@ // 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; /// @@ -55,7 +57,10 @@ public static IObservable OrchestrateMany - source.OrchestrateMany(new LambdaCacheOrchestrator(onSourceChangeSet, onInner, onDrainComplete)); + // Defer ensures a fresh LambdaCacheOrchestrator per subscription; the orchestrator holds + // mutable per-subscription state in _context/_emitter, so reusing one instance across + // subscribers would let later Initialize calls corrupt earlier subscribers' state. + Observable.Defer(() => source.OrchestrateMany(new LambdaCacheOrchestrator(onSourceChangeSet, onInner, onDrainComplete))); private sealed class LambdaCacheOrchestrator( Action, Action?>> onSourceChangeSet, diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs index d1539c94..e912c247 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs @@ -59,9 +59,14 @@ private sealed class ChangesOrchestrator(Func 0) + while (true) { + var captured = _cache.CaptureChanges(); + if (captured.Count == 0) + { + break; + } + Emitter.OnNext(captured); } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs index e727cf4b..70fe1a66 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs @@ -63,7 +63,16 @@ public MergedOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete() => _tracker.EmitChanges(Emitter); + public override void OnDrainComplete() + { + // Loop until tracker is fully drained. Each Emitter.OnNext triggers a reentrant drain + // that may deliver concurrent inner emissions into the tracker without firing + // OnDrainComplete for them; without this loop those items would sit in the tracker + // until another source/inner emission triggers a future drain. + while (_tracker.EmitChanges(Emitter)) + { + } + } protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs index bb706c45..41910411 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs @@ -53,7 +53,12 @@ public MergedListOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete() => _tracker.EmitChanges(Emitter); + public override void OnDrainComplete() + { + while (_tracker.EmitChanges(Emitter)) + { + } + } protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/Orchestration.cs b/src/DynamicData/Cache/Internal/Orchestration.cs index 7331a2ac..aa18d473 100644 --- a/src/DynamicData/Cache/Internal/Orchestration.cs +++ b/src/DynamicData/Cache/Internal/Orchestration.cs @@ -93,6 +93,22 @@ public void Track(TKey key, IObservable? observable) public IObservable Serialize(IObservable observable) => observable.SynchronizeSafe(_queue); + public IDisposable TrackAuxiliary(IObservable observable, Action onNext) + { + // Auxiliary subscriptions participate in completion accounting: counter is incremented + // here and decremented by Finally when the subscription ends (completion, error, or + // disposal). This keeps the downstream stream alive until any in-flight auxiliary + // work (typically a Buffer/Timer-driven flush) has had a chance to emit. + Interlocked.Increment(ref _subscriptionCounter); + + return observable + .SynchronizeSafe(_queue) + .Finally(DecrementSubscriptionCount) + .SubscribeSafe( + onNext: onNext, + onError: _emitter.OnError); + } + private void SubscribeToSource(IObservable> source) => _sourceSubscription.Disposable = source .SynchronizeSafe(_queue) diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index b099f662..bdd236bc 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -42,7 +42,12 @@ public Orchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete() => _tracker.EmitChanges(Emitter); + public override void OnDrainComplete() + { + while (_tracker.EmitChanges(Emitter)) + { + } + } protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/TransformOnObservable.cs b/src/DynamicData/Cache/Internal/TransformOnObservable.cs index d6d9af07..a10bb46a 100644 --- a/src/DynamicData/Cache/Internal/TransformOnObservable.cs +++ b/src/DynamicData/Cache/Internal/TransformOnObservable.cs @@ -50,9 +50,14 @@ public IObservable> Run() => Observable.Defer(() onInner: (value, key) => cache.AddOrUpdate(value, key), onDrainComplete: observer => { - var captured = cache.CaptureChanges(); - if (captured.Count > 0) + while (true) { + var captured = cache.CaptureChanges(); + if (captured.Count == 0) + { + break; + } + observer.OnNext(captured); } }); diff --git a/src/DynamicData/List/Internal/ChangeSetMergeTracker.cs b/src/DynamicData/List/Internal/ChangeSetMergeTracker.cs index fa2e2cec..af93b8aa 100644 --- a/src/DynamicData/List/Internal/ChangeSetMergeTracker.cs +++ b/src/DynamicData/List/Internal/ChangeSetMergeTracker.cs @@ -65,13 +65,16 @@ public void RemoveItems(IEnumerable removeItems, IObserver> observer) + public bool EmitChanges(IObserver> observer) { var changeSet = _resultList.CaptureChanges(); if (changeSet.Count != 0) { observer.OnNext(changeSet); + return true; } + + return false; } private void OnClear(Change change) => _resultList.ClearOrRemoveMany(change); From fd4b588d7fc3482a107697e496a4ab8e84b924ef Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 15:15:03 -0700 Subject: [PATCH 14/30] Add test coverage for OrchestrateMany contract gaps Three new tests in OrchestrateManyFixture: InnerError_PropagatesAndTerminatesStream - asserts that an OnError from a tracked inner observable terminates the merged stream with the same error and that completion does not also fire. SourceAlreadyCompleted_PropagatesCompletion - asserts that subscribing to an Observable.Empty source through the orchestrator immediately completes the downstream stream. SourceAlreadyErrored_PropagatesError - asserts that subscribing to an Observable.Throw source propagates the error through the orchestrator. --- .../Internal/OrchestrateManyFixture.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs index 2ebd4123..75e47c36 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reactive.Linq; using System.Reactive.Subjects; using System.Threading; using System.Threading.Tasks; @@ -203,6 +204,59 @@ public void Error_Propagates() 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 orchestrator = new TestOrchestrator(key => + { + var subj = new Subject(); + childSubjects.Add(subj); + return subj; + }); + using var sub = source.Connect().OrchestrateMany(orchestrator).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 orchestrator = new TestOrchestrator(); + using var sub = Observable.Empty>() + .OrchestrateMany(orchestrator) + .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 orchestrator = new TestOrchestrator(); + var error = new InvalidOperationException("sync-error"); + using var sub = Observable.Throw>(error) + .OrchestrateMany(orchestrator) + .Subscribe(observer); + + observer.Error.Should().BeSameAs(error, + "a synchronously-erroring source must propagate the error through the orchestrator"); + } + [Fact] public void Serialization_ParentAndChildDoNotInterleave() { From 563a6e4c56ec9e0bc097152072fddc4ef277fcc1 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 16:42:06 -0700 Subject: [PATCH 15/30] AutoRefresh: replace TrackAuxiliary with OnDrainComplete(bool) hook The previous fix added TrackAuxiliary to ICacheOrchestratorContext so that the buffered AutoRefresh timer subscription could participate in completion accounting. That polluted the orchestrator context with a method only one consumer needed. Replace it with a parameter on the existing OnDrainComplete hook: the runtime snapshots its completion latch BEFORE calling the orchestrator and passes the result as bool sourcesCompleted. AutoRefresh's buffered branch now flushes pending refreshes synchronously when sourcesCompleted is true, so the refresh surfaces before the runtime fires OnCompleted downstream and the timer subscription becomes purely opportunistic (no completion accounting needed). All ICacheOrchestrator implementations and OrchestratorCacheChangeBase updated for the new signature; consumers that did not need the flag simply ignore it. The lambda OrchestrateMany overload's onDrainComplete callback signature is unchanged (none of its current consumers need the flag). C2 regression test rewritten to match the new semantics: source + reevaluator completion now triggers an immediate synchronous flush rather than waiting for the buffer timer; the test verifies the refresh surfaces, completion happens immediately, and the timer is cancelled (no duplicate refresh at the original window boundary). Tests: 2405/2405 pass. TransformManyAsync flake check 30/30 pass. --- .../AutoRefreshOnObservableFixture.Base.cs | 14 +++---- .../Internal/OrchestrateManyFixture.cs | 2 +- src/DynamicData/Cache/Internal/AutoRefresh.cs | 15 ++++--- .../Cache/Internal/ICacheOrchestrator.cs | 8 +++- .../Internal/ICacheOrchestratorContext.cs | 13 ------ .../IntObservableCacheEx.OrchestrateMany.cs | 2 +- ...bservableCacheEx.OrchestrateManyChanges.cs | 2 +- ...ObservableCacheEx.OrchestrateManyMerged.cs | 2 +- ...rvableCacheEx.OrchestrateManyMergedList.cs | 2 +- .../Cache/Internal/Orchestration.cs | 40 +++++++------------ .../Internal/OrchestratorCacheChangeBase.cs | 2 +- .../Cache/Internal/TransformManyAsync.cs | 2 +- 12 files changed, 45 insertions(+), 59 deletions(-) diff --git a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs index 22575fa2..3cee9b63 100644 --- a/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs +++ b/src/DynamicData.Tests/Cache/AutoRefreshOnObservableFixture.Base.cs @@ -325,18 +325,18 @@ public void ChangeSetBufferIsGiven_SourceCompletesBeforeWindowExpires_PendingRef source.Complete(); item.Complete(); - results.HasCompleted.Should().BeFalse( - "completion must wait for the in-flight buffer window to flush its pending refresh"); + 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 the scheduler to the original window boundary) + // UUT Action (advance past the original window boundary; the timer was cancelled when sources completed) scheduler.AdvanceTo(TimeSpan.FromSeconds(15).Ticks); - 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( - "all upstream subscriptions and the buffer window have completed"); + "the pending timer was disposed when sources completed; no second refresh should fire at the window boundary"); } [Fact] diff --git a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs index 75e47c36..17dfba79 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs @@ -512,7 +512,7 @@ public void OnInner(string child, int parentKey) _cache.AddOrUpdate(new TestItem(parentKey, child), parentKey); } - public void OnDrainComplete() + public void OnDrainComplete(bool sourcesCompleted) { var changes = _cache.CaptureChanges(); if (changes.Count > 0) diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index e05ec847..df353eaf 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -6,6 +6,8 @@ using System.Reactive.Disposables; using System.Reactive.Linq; +using DynamicData.Internal; + namespace DynamicData.Cache.Internal; internal sealed class AutoRefresh( @@ -64,17 +66,20 @@ public override void OnInner(Change refresh, TKey key) if (buffer is { } window) { - // TrackAuxiliary contributes to completion accounting so source/inner completion - // cannot terminate the stream while a buffered refresh is still pending. - _timerSubscription.Disposable ??= Context.TrackAuxiliary(Observable.Timer(window, scheduler!), _ => FlushPending()); + _timerSubscription.Disposable ??= Context.Serialize(Observable.Timer(window, scheduler!)) + .SubscribeSafe(_ => FlushPending(), Emitter.OnError); } } - public override void OnDrainComplete() + public override void OnDrainComplete(bool sourcesCompleted) { _sourceTouched.Clear(); - if (buffer is null) + // When sources have all completed, flush any pending refreshes synchronously here so they + // surface before the runtime fires OnCompleted downstream. This covers both the unbuffered + // path (which always flushes per drain) and the buffered path whose timer would otherwise + // be cancelled by the imminent stream termination. + if (sourcesCompleted || buffer is null) { // Loop until pending is empty: each Emitter.OnNext triggers a reentrant drain that // may add new refreshes via OnInner before the orchestrator regains control. diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs index 19667f67..809ce84e 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs @@ -49,5 +49,11 @@ internal interface ICacheOrchestrator /// 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. /// - void OnDrainComplete(); + /// + /// when the source changeset and every tracked inner observable have all + /// completed, signalling that this is the last opportunity to emit before the downstream + /// observer receives OnCompleted. Implementations holding deferred state (timer-armed + /// buffers, debounced batches) should flush synchronously when this is . + /// + void OnDrainComplete(bool sourcesCompleted); } diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs index 6a8bce6d..4cefd1fb 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs @@ -35,17 +35,4 @@ internal interface ICacheOrchestratorContext /// The observable to wrap. /// A new observable whose OnNext delivery runs under the orchestrator's queue lock. IObservable Serialize(IObservable observable); - - /// - /// Subscribes to an auxiliary observable whose lifetime contributes to the orchestrator's - /// completion accounting. The downstream stream cannot complete until every auxiliary - /// subscription either completes or is disposed. Emissions are routed through the shared - /// delivery queue and dispatched to ; errors propagate to the - /// downstream emitter. - /// - /// Value type emitted by the auxiliary observable. - /// The auxiliary observable. - /// Callback invoked under the queue lock for each emission. - /// A disposable that cancels the subscription. Disposing decrements completion accounting. - IDisposable TrackAuxiliary(IObservable observable, Action onNext); } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs index ba237218..24a5478d 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs @@ -84,6 +84,6 @@ public void Initialize(ICacheOrchestratorContext context, IObserve public void OnInner(TInner value, TKey key) => onInner(value, key); - public void OnDrainComplete() => onDrainComplete(_emitter); + public void OnDrainComplete(bool sourcesCompleted) => onDrainComplete(_emitter); } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs index e912c247..c147d149 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs @@ -57,7 +57,7 @@ private sealed class ChangesOrchestrator(Func onInner(_cache, key, value.Item, value.Value); - public override void OnDrainComplete() + public override void OnDrainComplete(bool sourcesCompleted) { while (true) { diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs index 70fe1a66..544fb575 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs @@ -63,7 +63,7 @@ public MergedOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete() + public override void OnDrainComplete(bool sourcesCompleted) { // Loop until tracker is fully drained. Each Emitter.OnNext triggers a reentrant drain // that may deliver concurrent inner emissions into the tracker without firing diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs index 41910411..80376e6f 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs @@ -53,7 +53,7 @@ public MergedListOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete() + public override void OnDrainComplete(bool sourcesCompleted) { while (_tracker.EmitChanges(Emitter)) { diff --git a/src/DynamicData/Cache/Internal/Orchestration.cs b/src/DynamicData/Cache/Internal/Orchestration.cs index aa18d473..2d9355bf 100644 --- a/src/DynamicData/Cache/Internal/Orchestration.cs +++ b/src/DynamicData/Cache/Internal/Orchestration.cs @@ -47,7 +47,13 @@ public OrchestratorContext(IObservable> source, IObser // emitter after that work has settled. _emitter = _queue.CreateQueue(observer); _orchestrator.Initialize(this, _emitter); - SubscribeToSource(source); + + _sourceSubscription.Disposable = source + .SynchronizeSafe(_queue) + .SubscribeSafe( + onNext: OnSourceChangeSet, + onError: _emitter.OnError, + onCompleted: DecrementSubscriptionCount); } public void Dispose() @@ -93,30 +99,6 @@ public void Track(TKey key, IObservable? observable) public IObservable Serialize(IObservable observable) => observable.SynchronizeSafe(_queue); - public IDisposable TrackAuxiliary(IObservable observable, Action onNext) - { - // Auxiliary subscriptions participate in completion accounting: counter is incremented - // here and decremented by Finally when the subscription ends (completion, error, or - // disposal). This keeps the downstream stream alive until any in-flight auxiliary - // work (typically a Buffer/Timer-driven flush) has had a chance to emit. - Interlocked.Increment(ref _subscriptionCounter); - - return observable - .SynchronizeSafe(_queue) - .Finally(DecrementSubscriptionCount) - .SubscribeSafe( - onNext: onNext, - onError: _emitter.OnError); - } - - private void SubscribeToSource(IObservable> source) => - _sourceSubscription.Disposable = source - .SynchronizeSafe(_queue) - .SubscribeSafe( - onNext: OnSourceChangeSet, - onError: _emitter.OnError, - onCompleted: DecrementSubscriptionCount); - private void OnSourceChangeSet(IChangeSet changes) { try @@ -143,9 +125,15 @@ private void OnInner(TInner value, TKey key) private void OnDrainComplete() { + // Snapshot before calling the orchestrator: a reentrant drain triggered by the orchestrator's + // own emit can land here recursively, latch _isCompleted off, and complete the stream + // synchronously. When control returns to the outer call, the snapshot still tells the + // orchestrator that source and tracked inners are done. + var sourcesCompleted = Volatile.Read(ref _isCompleted); + try { - _orchestrator.OnDrainComplete(); + _orchestrator.OnDrainComplete(sourcesCompleted); } catch (Exception error) { diff --git a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs index 995f6e81..1b2662a1 100644 --- a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs +++ b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs @@ -61,7 +61,7 @@ public virtual void OnSourceChangeSet(IChangeSet changes) public abstract void OnInner(TInner value, TKey key); - public virtual void OnDrainComplete() + public virtual void OnDrainComplete(bool sourcesCompleted) { } diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index bdd236bc..4763be35 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -42,7 +42,7 @@ public Orchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete() + public override void OnDrainComplete(bool sourcesCompleted) { while (_tracker.EmitChanges(Emitter)) { From de681f8007c74c74b9e657f4f06b6a0ec785e136 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 7 Jun 2026 16:58:54 -0700 Subject: [PATCH 16/30] Orchestrator: factory-based construction instead of Initialize hook Change OrchestrateMany to accept Func instead of a pre-constructed orchestrator instance. The runtime invokes the factory once per subscription, passing the per-subscription context and downstream emitter; the orchestrator captures both as readonly constructor params and is fully initialized when its first method is called. Eliminates: - Initialize method on ICacheOrchestrator (one fewer interface method) - The 'null!' partial-init zone in every orchestrator (_context, _emitter fields no longer needed) - The Observable.Defer wrapper around the lambda OrchestrateMany overload (factory pattern makes per-subscription instances structurally required, so H1's multi-subscribe corruption hazard cannot recur) - The redundant Observable.Create wrappers in MergeMany, MergeManyItems, TransformManyAsync, OrchestrateManyMerged, OrchestrateManyMergedList, OrchestrateManyChanges Run() methods (OrchestrateMany already creates per-subscription state) OrchestratorCacheChangeBase is now a primary-constructor abstract class that forwards context/emitter via constructor chaining. Derived orchestrators (Orchestrator inside AutoRefresh/MergeMany/MergeManyItems/TransformManyAsync, MergedOrchestrator, MergedListOrchestrator, ChangesOrchestrator, LambdaCacheOrchestrator, and the test TestOrchestrator) take context and emitter as their first two constructor parameters and chain to the base. Test orchestrator construction in OrchestrateManyFixture is wrapped by a Wire(...) helper that returns the observable plus a Func thunk; tests call Wire then subscribe to capture the per-subscription orchestrator instance. Tests: 2405/2405 pass. TransformManyAsync flake check 30/30 pass. --- .../Internal/OrchestrateManyFixture.cs | 135 +++++++++--------- src/DynamicData/Cache/Internal/AutoRefresh.cs | 13 +- .../Cache/Internal/ICacheOrchestrator.cs | 16 +-- .../IntObservableCacheEx.OrchestrateMany.cs | 38 ++--- ...bservableCacheEx.OrchestrateManyChanges.cs | 14 +- ...ObservableCacheEx.OrchestrateManyMerged.cs | 8 +- ...rvableCacheEx.OrchestrateManyMergedList.cs | 8 +- src/DynamicData/Cache/Internal/MergeMany.cs | 14 +- .../Cache/Internal/MergeManyItems.cs | 14 +- .../Cache/Internal/Orchestration.cs | 21 ++- .../Internal/OrchestratorCacheChangeBase.cs | 18 +-- .../Cache/Internal/TransformManyAsync.cs | 9 +- 12 files changed, 158 insertions(+), 150 deletions(-) diff --git a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs index 17dfba79..3488ee25 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs @@ -39,14 +39,32 @@ public sealed class OrchestrateManyFixture /// Test item with a typed key. private sealed record TestItem(int Key, string Value); + /// + /// Wires through OrchestrateMany with a fresh + /// constructed per subscription. Returns the observable plus a thunk that yields the constructed + /// orchestrator after subscribe. The factory pattern ensures per-subscription isolation and + /// matches the production OrchestrateMany contract. + /// + private static (IObservable> Observable, Func Orchestrator) Wire( + IObservable> source, + Func>? childFactory = null, + Action? onParent = null, + Action? onChild = null) + { + TestOrchestrator? captured = null; + var observable = source.OrchestrateMany>( + (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 orchestrator = new TestOrchestrator(); - using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + 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)))) @@ -55,7 +73,7 @@ public void ParentOnNext_CalledForEachChangeSet() foreach (var item in items) source.AddOrUpdate(item); - orchestrator.ParentCallCount.Should().Be(items.Count, "OnSourceChangeSet should fire once per changeset"); + 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"); } @@ -65,13 +83,13 @@ public void ChildOnNext_CalledForEachEmission() using var source = new SourceCache(x => x.Key); var childSubjects = new List>(); var observer = new TestObserver(); - var orchestrator = new TestOrchestrator(key => + var (observable, getOrchestrator) = Wire(source.Connect(), key => { var subj = new Subject(); childSubjects.Add(subj); return subj; }); - using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + using var sub = observable.Subscribe(observer); var key = _rand.Number(SeedMin, SeedMax); source.AddOrUpdate(new TestItem(key, "parent")); @@ -80,7 +98,7 @@ public void ChildOnNext_CalledForEachEmission() var childValue = _rand.String2(_rand.Number(5, 15)); childSubjects[0].OnNext(childValue); - orchestrator.ChildCalls.Should().ContainSingle() + getOrchestrator().ChildCalls.Should().ContainSingle() .Which.Should().Be((childValue, key)); } @@ -90,8 +108,8 @@ public void EmitChanges_FiresOnceForBatch() var batchSize = _rand.Number(BatchSizeMin, BatchSizeMax); using var source = new SourceCache(x => x.Key); var observer = new TestObserver(); - var orchestrator = new TestOrchestrator(); - using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + var (observable, getOrchestrator) = Wire(source.Connect()); + using var sub = observable.Subscribe(observer); source.Edit(updater => { @@ -99,6 +117,7 @@ public void EmitChanges_FiresOnceForBatch() 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"); } @@ -110,12 +129,12 @@ public void Batching_ChildUpdatesSettleBeforeEmit() using var source = new SourceCache(x => x.Key); var observer = new TestObserver(); var childCount = 0; - var orchestrator = new TestOrchestrator(key => + var (observable, getOrchestrator) = Wire(source.Connect(), key => { Interlocked.Increment(ref childCount); return new BehaviorSubject($"sync-{key}"); }); - using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + using var sub = observable.Subscribe(observer); source.Edit(updater => { @@ -124,7 +143,7 @@ public void Batching_ChildUpdatesSettleBeforeEmit() }); childCount.Should().Be(batchSize, "each item should create a child"); - orchestrator.EmitCallCount.Should().BeGreaterThanOrEqualTo(1, + getOrchestrator().EmitCallCount.Should().BeGreaterThanOrEqualTo(1, "Emit fires after parent + children settle"); } @@ -134,13 +153,13 @@ public void Completion_RequiresParentAndAllChildren() using var source = new TestSourceCache(x => x.Key); var childSubjects = new List>(); var observer = new TestObserver(); - var orchestrator = new TestOrchestrator(key => + var (observable, _) = Wire(source.Connect(), key => { var subj = new Subject(); childSubjects.Add(subj); return subj; }); - using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + using var sub = observable.Subscribe(observer); source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); childSubjects.Should().HaveCount(1); @@ -157,8 +176,8 @@ public void Completion_ParentOnly_NoChildren() { using var source = new TestSourceCache(x => x.Key); var observer = new TestObserver(); - var orchestrator = new TestOrchestrator(); - using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + var (observable, _) = Wire(source.Connect()); + using var sub = observable.Subscribe(observer); source.Complete(); observer.IsCompleted.Should().BeTrue("immediate OnCompleted when no children"); @@ -170,13 +189,13 @@ public void Disposal_StopsAllEmissions() using var source = new SourceCache(x => x.Key); var childSubjects = new List>(); var observer = new TestObserver(); - var orchestrator = new TestOrchestrator(key => + var (observable, _) = Wire(source.Connect(), key => { var subj = new Subject(); childSubjects.Add(subj); return subj; }); - var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + var sub = observable.Subscribe(observer); source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); var emitsBefore = observer.EmitCount; @@ -195,8 +214,8 @@ public void Error_Propagates() { using var source = new TestSourceCache(x => x.Key); var observer = new TestObserver(); - var orchestrator = new TestOrchestrator(); - using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + var (observable, _) = Wire(source.Connect()); + using var sub = observable.Subscribe(observer); var error = new InvalidOperationException("test error"); source.SetError(error); @@ -210,13 +229,13 @@ public void InnerError_PropagatesAndTerminatesStream() using var source = new SourceCache(x => x.Key); var childSubjects = new List>(); var observer = new TestObserver(); - var orchestrator = new TestOrchestrator(key => + var (observable, _) = Wire(source.Connect(), key => { var subj = new Subject(); childSubjects.Add(subj); return subj; }); - using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + using var sub = observable.Subscribe(observer); source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); childSubjects.Should().HaveCount(1); @@ -234,10 +253,8 @@ public void InnerError_PropagatesAndTerminatesStream() public void SourceAlreadyCompleted_PropagatesCompletion() { var observer = new TestObserver(); - var orchestrator = new TestOrchestrator(); - using var sub = Observable.Empty>() - .OrchestrateMany(orchestrator) - .Subscribe(observer); + 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"); @@ -247,11 +264,9 @@ public void SourceAlreadyCompleted_PropagatesCompletion() public void SourceAlreadyErrored_PropagatesError() { var observer = new TestObserver(); - var orchestrator = new TestOrchestrator(); var error = new InvalidOperationException("sync-error"); - using var sub = Observable.Throw>(error) - .OrchestrateMany(orchestrator) - .Subscribe(observer); + 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"); @@ -263,11 +278,12 @@ public void Serialization_ParentAndChildDoNotInterleave() using var source = new SourceCache(x => x.Key); var callLog = new List(); var observer = new TestObserver(); - var orchestrator = new TestOrchestrator( - key => new Subject(), + var (observable, _) = Wire( + source.Connect(), + childFactory: key => new Subject(), 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 = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + using var sub = observable.Subscribe(observer); source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); @@ -295,12 +311,12 @@ public async Task DeadlockProof_CrossFeedingSubscriptions() using var sourceB = new SourceCache(x => x.Key); var observerA = new CrossFeedObserver(sourceB, 100_001, iterations); - var orchestratorA = new TestOrchestrator(); - using var subA = sourceA.Connect().OrchestrateMany(orchestratorA).Subscribe(observerA); + var (observableA, _) = Wire(sourceA.Connect()); + using var subA = observableA.Subscribe(observerA); var observerB = new CrossFeedObserver(sourceA, 200_001, iterations); - var orchestratorB = new TestOrchestrator(); - using var subB = sourceB.Connect().OrchestrateMany(orchestratorB).Subscribe(observerB); + var (observableB, _) = Wire(sourceB.Connect()); + using var subB = observableB.Subscribe(observerB); using var barrier = new Barrier(2); @@ -348,9 +364,9 @@ public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstrea innerSubjects[i] = new Subject(); } - var orchestrator = new TestOrchestrator(key => innerSubjects[key]); + var (observable, getOrchestrator) = Wire(source.Connect(), key => innerSubjects[key]); var observer = new TestObserver(); - using var sub = source.Connect().OrchestrateMany(orchestrator).Subscribe(observer); + using var sub = observable.Subscribe(observer); // Add a source item per producer to subscribe each inner subject. for (var i = 1; i <= producerCount; i++) @@ -358,6 +374,8 @@ public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstrea source.AddOrUpdate(new TestItem(i, "init")); } + var orchestrator = getOrchestrator(); + // Reset child-call tracking captured during init so we count only the burst below. lock (orchestrator.ChildCalls) { @@ -458,56 +476,41 @@ public void OnCompleted() { } /// /// Minimal ICacheOrchestrator implementation that mirrors the legacy CPS-subclass shape. /// - private sealed class TestOrchestrator : ICacheOrchestrator> + private sealed class TestOrchestrator( + ICacheOrchestratorContext context, + IObserver> emitter, + Func>? childFactory = null, + Action? onParent = null, + Action? onChild = null) + : ICacheOrchestrator> { - 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 = []; - private ICacheOrchestratorContext _context = null!; - private IObserver> _emitter = null!; - - public TestOrchestrator( - Func>? childFactory = null, - Action? onParent = null, - Action? onChild = null) - { - _childFactory = childFactory; - _onParent = onParent; - _onChild = onChild; - } - - public void Initialize(ICacheOrchestratorContext context, IObserver> emitter) - { - _context = context; - _emitter = emitter; - } public void OnSourceChangeSet(IChangeSet changes) { Interlocked.Increment(ref ParentCallCount); - _onParent?.Invoke(); + onParent?.Invoke(); _cache.Clone(changes); - if (_childFactory is not null) + 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)); + context.Track(change.Key, childFactory(change.Key)); else if (change.Reason is ChangeReason.Remove) - _context.Track(change.Key, null); + context.Track(change.Key, null); } } } public void OnInner(string child, int parentKey) { - _onChild?.Invoke(); + onChild?.Invoke(); ChildCalls.Add((child, parentKey)); _cache.AddOrUpdate(new TestItem(parentKey, child), parentKey); } @@ -518,7 +521,7 @@ public void OnDrainComplete(bool sourcesCompleted) if (changes.Count > 0) { Interlocked.Increment(ref EmitCallCount); - _emitter.OnNext(changes); + emitter.OnNext(changes); } } } diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index df353eaf..66d53e26 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -18,11 +18,12 @@ internal sealed class AutoRefresh( where TObject : notnull where TKey : notnull { - public IObservable> Run() => Observable.Create>(observer => + public IObservable> Run() { - var orchestrator = new Orchestrator(reEvaluator, buffer, buffer is null ? null : scheduler ?? GlobalConfig.DefaultScheduler); - return source.OrchestrateMany(orchestrator).SubscribeSafe(observer); - }); + var sched = buffer is null ? null : scheduler ?? GlobalConfig.DefaultScheduler; + return source.OrchestrateMany, IChangeSet>( + (context, emitter) => new Orchestrator(context, emitter, reEvaluator, buffer, sched)); + } /// /// Forwards each source changeset to the emitter immediately. Refresh notifications from @@ -35,10 +36,12 @@ public IObservable> Run() => Observable.Create private sealed class Orchestrator( + ICacheOrchestratorContext> context, + IObserver> emitter, Func> reEvaluator, TimeSpan? buffer, IScheduler? scheduler) - : OrchestratorCacheChangeBase, IChangeSet>, IDisposable + : OrchestratorCacheChangeBase, IChangeSet>(context, emitter), IDisposable { private readonly Dictionary> _pendingRefreshes = new(); private readonly HashSet _sourceTouched = []; diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs index 809ce84e..81781bd7 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs @@ -7,8 +7,9 @@ namespace DynamicData.Cache.Internal; /// /// Orchestrator contract consumed by the OrchestrateMany primitive. Implementations hold /// per-subscription state as fields and receive their -/// and downstream emitter once via -/// before any other method is called. +/// and downstream emitter as constructor +/// arguments supplied by the factory passed to OrchestrateMany. A new orchestrator instance +/// is constructed per subscription, so all state is naturally isolated. /// /// Type of items in the source changeset. /// Type of the source changeset key. @@ -19,17 +20,6 @@ internal interface ICacheOrchestrator where TKey : notnull where TInner : notnull { - /// - /// Invoked exactly once by OrchestrateMany before subscribing to the source. Implementations - /// should capture and in private fields for - /// use by subsequent method calls. The supplied is a sub-queue of the - /// orchestrator's shared delivery queue: every OnNext/OnError/OnCompleted on - /// it is automatically serialized with source and inner notifications. - /// - /// The runtime context, scoped to this subscription's lifetime. - /// The downstream observer, fronted by a serializing sub-queue. - void Initialize(ICacheOrchestratorContext context, IObserver emitter); - /// /// Invoked for each source changeset. /// diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs index 24a5478d..e965ae1b 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs @@ -2,8 +2,6 @@ // 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; /// @@ -15,28 +13,29 @@ 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 owns the per-subscription - /// state and is wired to an via - /// . + /// 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. /// The keyed source changeset stream. - /// The orchestrator implementation. + /// 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 OrchestrateMany( this IObservable> source, - ICacheOrchestrator orchestrator) + Func, IObserver, ICacheOrchestrator> factory) where TSource : notnull where TKey : notnull where TInner : notnull => - new Orchestration(source, orchestrator).Run(); + new Orchestration(source, factory).Run(); /// /// Convenience overload that wraps three lambdas into an - /// and delegates to . + /// and delegates to . /// Does not expose ; operators that /// need it must implement directly. /// @@ -57,12 +56,12 @@ public static IObservable OrchestrateMany - // Defer ensures a fresh LambdaCacheOrchestrator per subscription; the orchestrator holds - // mutable per-subscription state in _context/_emitter, so reusing one instance across - // subscribers would let later Initialize calls corrupt earlier subscribers' state. - Observable.Defer(() => source.OrchestrateMany(new LambdaCacheOrchestrator(onSourceChangeSet, onInner, onDrainComplete))); + source.OrchestrateMany( + (context, emitter) => new LambdaCacheOrchestrator(context, emitter, onSourceChangeSet, onInner, onDrainComplete)); private sealed class LambdaCacheOrchestrator( + ICacheOrchestratorContext context, + IObserver emitter, Action, Action?>> onSourceChangeSet, Action onInner, Action> onDrainComplete) @@ -71,19 +70,10 @@ private sealed class LambdaCacheOrchestrator( where TKey : notnull where TInner : notnull { - private ICacheOrchestratorContext _context = null!; - private IObserver _emitter = null!; - - public void Initialize(ICacheOrchestratorContext context, IObserver emitter) - { - _context = context; - _emitter = emitter; - } - - public void OnSourceChangeSet(IChangeSet changes) => onSourceChangeSet(changes, _context.Track); + public void OnSourceChangeSet(IChangeSet changes) => onSourceChangeSet(changes, context.Track); public void OnInner(TInner value, TKey key) => onInner(value, key); - public void OnDrainComplete(bool sourcesCompleted) => onDrainComplete(_emitter); + public void OnDrainComplete(bool sourcesCompleted) => onDrainComplete(emitter); } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs index c147d149..73707f08 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs @@ -42,12 +42,16 @@ public static IObservable> OrchestrateManyChanges - Observable.Create>(observer => - source.OrchestrateMany(new ChangesOrchestrator(innerFactory, onSourceChange, onInner)) - .SubscribeSafe(observer)); + source.OrchestrateMany>( + (context, emitter) => new ChangesOrchestrator(context, emitter, innerFactory, onSourceChange, onInner)); - private sealed class ChangesOrchestrator(Func> innerFactory, Action, Change> onSourceChange, Action, TKey, TSource, TInner> onInner) - : OrchestratorCacheChangeBase> + private sealed class ChangesOrchestrator( + ICacheOrchestratorContext context, + IObserver> emitter, + Func> innerFactory, + Action, Change> onSourceChange, + Action, TKey, TSource, TInner> onInner) + : OrchestratorCacheChangeBase>(context, emitter) where TSource : notnull where TKey : notnull where TInner : notnull diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs index 544fb575..73338677 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs @@ -35,9 +35,8 @@ public static IObservable> OrchestrateManyMerged - Observable.Create>(observer => - source.OrchestrateMany(new MergedOrchestrator(changeSetSelector, equalityComparer, comparer, reevalOnRefresh)) - .SubscribeSafe(observer)); + source.OrchestrateMany, IChangeSet>( + (context, emitter) => new MergedOrchestrator(context, emitter, changeSetSelector, equalityComparer, comparer, reevalOnRefresh)); private sealed class MergedOrchestrator : OrchestratorCacheChangeBase, IChangeSet> where TSource : notnull @@ -51,10 +50,13 @@ private sealed class MergedOrchestrator : Orches 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; diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs index 80376e6f..413ed5c7 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs @@ -29,9 +29,8 @@ public static IObservable> OrchestrateManyMergedList - Observable.Create>(observer => - source.OrchestrateMany(new MergedListOrchestrator(changeSetSelector, equalityComparer)) - .SubscribeSafe(observer)); + source.OrchestrateMany, IChangeSet>( + (context, emitter) => new MergedListOrchestrator(context, emitter, changeSetSelector, equalityComparer)); private sealed class MergedListOrchestrator : OrchestratorCacheChangeBase, IChangeSet> where TSource : notnull @@ -44,8 +43,11 @@ private sealed class MergedListOrchestrator : Orchestrator private readonly IEqualityComparer? _equalityComparer; public MergedListOrchestrator( + ICacheOrchestratorContext> context, + IObserver> emitter, Func>> changeSetSelector, IEqualityComparer? equalityComparer) + : base(context, emitter) { _changeSetSelector = changeSetSelector; _equalityComparer = equalityComparer; diff --git a/src/DynamicData/Cache/Internal/MergeMany.cs b/src/DynamicData/Cache/Internal/MergeMany.cs index 653550b1..59d1c50f 100644 --- a/src/DynamicData/Cache/Internal/MergeMany.cs +++ b/src/DynamicData/Cache/Internal/MergeMany.cs @@ -27,11 +27,15 @@ public MergeMany(IObservable> source, Func observableSelector(t); } - public IObservable Run() => Observable.Create(observer => - _source.OrchestrateMany(new Orchestrator(_observableSelector)).SubscribeSafe(observer)); - - private sealed class Orchestrator(Func> selector) - : OrchestratorCacheChangeBase + public IObservable Run() => + _source.OrchestrateMany( + (context, emitter) => new Orchestrator(context, emitter, _observableSelector)); + + private sealed class Orchestrator( + ICacheOrchestratorContext context, + IObserver emitter, + Func> selector) + : OrchestratorCacheChangeBase(context, emitter) { public override void OnInner(TDestination value, TKey key) => Emitter.OnNext(value); diff --git a/src/DynamicData/Cache/Internal/MergeManyItems.cs b/src/DynamicData/Cache/Internal/MergeManyItems.cs index cd10c8f5..97316720 100644 --- a/src/DynamicData/Cache/Internal/MergeManyItems.cs +++ b/src/DynamicData/Cache/Internal/MergeManyItems.cs @@ -27,11 +27,15 @@ public MergeManyItems(IObservable> source, Func observableSelector(t); } - public IObservable> Run() => Observable.Create>(observer => - _source.OrchestrateMany(new Orchestrator(_observableSelector)).SubscribeSafe(observer)); - - private sealed class Orchestrator(Func> selector) - : OrchestratorCacheChangeBase> + public IObservable> Run() => + _source.OrchestrateMany>( + (context, emitter) => new Orchestrator(context, emitter, _observableSelector)); + + private sealed class Orchestrator( + ICacheOrchestratorContext context, + IObserver> emitter, + Func> selector) + : OrchestratorCacheChangeBase>(context, emitter) { public override void OnInner((TObject Item, TDestination Value) value, TKey key) => Emitter.OnNext(new ItemWithValue(value.Item, value.Value)); diff --git a/src/DynamicData/Cache/Internal/Orchestration.cs b/src/DynamicData/Cache/Internal/Orchestration.cs index 2d9355bf..66ac782e 100644 --- a/src/DynamicData/Cache/Internal/Orchestration.cs +++ b/src/DynamicData/Cache/Internal/Orchestration.cs @@ -13,18 +13,25 @@ namespace DynamicData.Cache.Internal; /// Drives an against a source /// changeset. returns an that constructs a /// fresh per-subscription on each subscribe, so all per-subscription -/// state owned by the is recreated on every subscribe. +/// state owned by the is recreated on every subscribe. The +/// orchestrator itself is constructed by the supplied , which receives +/// the per-subscription context and emitter; this guarantees a fresh orchestrator instance per +/// subscriber and removes the need for a separate Initialize hook. /// /// 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. -internal sealed class Orchestration(IObservable> source, ICacheOrchestrator orchestrator) +/// The keyed source changeset stream. +/// Builds the per-subscription orchestrator from its runtime context and emitter. +internal sealed class Orchestration( + IObservable> source, + Func, IObserver, ICacheOrchestrator> factory) where TSource : notnull where TKey : notnull where TInner : notnull { - public IObservable Run() => Observable.Create(observer => new OrchestratorContext(source, observer, orchestrator)); + public IObservable Run() => Observable.Create(observer => new OrchestratorContext(source, observer, factory)); private sealed class OrchestratorContext : ICacheOrchestratorContext, IDisposable { @@ -37,16 +44,18 @@ private sealed class OrchestratorContext : ICacheOrchestratorContext> source, IObserver observer, ICacheOrchestrator orchestrator) + public OrchestratorContext( + IObservable> source, + IObserver observer, + Func, IObserver, ICacheOrchestrator> factory) { - _orchestrator = orchestrator; _queue = new SharedDeliveryQueue(onDrainComplete: OnDrainComplete); // Create the emitter sub-queue first (lowest index, drains last LIFO) so source-triggered // sync inner emissions (higher index) deliver first and any orchestrator emit lands on the // emitter after that work has settled. _emitter = _queue.CreateQueue(observer); - _orchestrator.Initialize(this, _emitter); + _orchestrator = factory(this, _emitter); _sourceSubscription.Disposable = source .SynchronizeSafe(_queue) diff --git a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs index 1b2662a1..8e67217f 100644 --- a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs +++ b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs @@ -16,23 +16,17 @@ namespace DynamicData.Cache.Internal; /// 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 OrchestratorCacheChangeBase : ICacheOrchestrator +internal abstract class OrchestratorCacheChangeBase( + ICacheOrchestratorContext context, + IObserver emitter) + : ICacheOrchestrator where TSource : notnull where TKey : notnull where TInner : notnull { - private ICacheOrchestratorContext _context = null!; - private IObserver _emitter = null!; + protected ICacheOrchestratorContext Context => context; - protected ICacheOrchestratorContext Context => _context; - - protected IObserver Emitter => _emitter; - - public void Initialize(ICacheOrchestratorContext context, IObserver emitter) - { - _context = context; - _emitter = emitter; - } + protected IObserver Emitter => emitter; public virtual void OnSourceChangeSet(IChangeSet changes) { diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index 4763be35..95bbfbdf 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -18,9 +18,9 @@ internal sealed class TransformManyAsync> Run() => Observable.Create>(observer => - source.OrchestrateMany(new Orchestrator(transformer, equalityComparer, comparer, errorHandler)) - .SubscribeSafe(observer)); + public IObservable> Run() => + source.OrchestrateMany, IChangeSet>( + (context, emitter) => new Orchestrator(context, emitter, transformer, equalityComparer, comparer, errorHandler)); private sealed class Orchestrator : OrchestratorCacheChangeBase, IChangeSet> { @@ -30,10 +30,13 @@ private sealed class Orchestrator : OrchestratorCacheChangeBase>? _errorHandler; public Orchestrator( + ICacheOrchestratorContext> context, + IObserver> emitter, Func>>> transformer, IEqualityComparer? equalityComparer, IComparer? comparer, Action>? errorHandler) + : base(context, emitter) { _transformer = transformer; _errorHandler = errorHandler; From a07e8fb0aced1d3d5e8df8ad8b39246808cbdc63 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 8 Jun 2026 06:27:07 -0700 Subject: [PATCH 17/30] SharedDeliveryQueue: handle drain-reentrancy in queue, not consumers The C1 fix previously required every orchestrator's OnDrainComplete to loop until its tracker reported empty. The hazard was actually a queue-level lifecycle gap: when OnDrainComplete called Emitter.OnNext, ExitLockAndDrain took the reentrant path and drained items inline but left no trace; the outer DrainAll then checked _activeBits, saw it empty, and exited without re-firing OnDrainComplete. State accumulated during the reentrant drain (e.g. ChangeSetMergeTracker entries) was stranded until the next unrelated drain. Move the fix to SharedDeliveryQueue: set a _drainReentered flag in the reentrant path; reset it before each OnDrainComplete callback; loop back in DrainAll when either _activeBits.HasAny() OR _drainReentered. The flag is only touched by the drain thread, so no synchronization is needed. Removes: - bool return from ChangeSetMergeTracker.EmitChanges (Cache + List) - bool return from DynamicGrouper.EmitChanges - per-consumer while-loops in AutoRefresh, TransformManyAsync, OrchestrateManyMerged, OrchestrateManyMergedList, OrchestrateManyChanges, TransformOnObservable, GroupOnObservable Every orchestrator's OnDrainComplete now emits at most once per call; the queue re-calls them whenever a reentrant drain may have updated their state. Tests: 2405/2405 pass. TransformManyAsync flake check 30/30 pass. --- src/DynamicData/Cache/Internal/AutoRefresh.cs | 15 +++++---------- .../Cache/Internal/ChangeSetMergeTracker.cs | 6 +----- .../Cache/Internal/DynamicGrouper.cs | 5 +---- .../Cache/Internal/GroupOnObservable.cs | 7 +------ ...bservableCacheEx.OrchestrateManyChanges.cs | 9 ++------- ...ObservableCacheEx.OrchestrateManyMerged.cs | 11 +---------- ...rvableCacheEx.OrchestrateManyMergedList.cs | 7 +------ .../Cache/Internal/TransformManyAsync.cs | 7 +------ .../Cache/Internal/TransformOnObservable.cs | 9 ++------- .../Internal/SharedDeliveryQueue.cs | 19 ++++++++++++------- .../List/Internal/ChangeSetMergeTracker.cs | 5 +---- 11 files changed, 28 insertions(+), 72 deletions(-) diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index 66d53e26..d644e54f 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -78,18 +78,13 @@ public override void OnDrainComplete(bool sourcesCompleted) { _sourceTouched.Clear(); - // When sources have all completed, flush any pending refreshes synchronously here so they - // surface before the runtime fires OnCompleted downstream. This covers both the unbuffered - // path (which always flushes per drain) and the buffered path whose timer would otherwise - // be cancelled by the imminent stream termination. + // Flush pending refreshes whenever there is no timer-based deferral active + // (unbuffered) or when sources have completed (so the timer would never fire + // before downstream completion). The queue re-fires OnDrainComplete after any + // reentrant drain triggered by FlushPending, so a single emit per call suffices. if (sourcesCompleted || buffer is null) { - // Loop until pending is empty: each Emitter.OnNext triggers a reentrant drain that - // may add new refreshes via OnInner before the orchestrator regains control. - while (_pendingRefreshes.Count > 0) - { - FlushPending(); - } + FlushPending(); } } diff --git a/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs b/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs index 1793feac..ae973094 100644 --- a/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs +++ b/src/DynamicData/Cache/Internal/ChangeSetMergeTracker.cs @@ -100,22 +100,18 @@ public void ProcessChangeSet(IChangeSet changes, IObserver> observer) + public void EmitChanges(IObserver> observer) { var changeSet = _resultCache.CaptureChanges(); - var emitted = false; if (changeSet.Count != 0) { observer.OnNext(changeSet); - emitted = true; } if (_hasCompleted) { observer.OnCompleted(); } - - return emitted; } private void OnItemAdded(TObject item, TKey key) diff --git a/src/DynamicData/Cache/Internal/DynamicGrouper.cs b/src/DynamicData/Cache/Internal/DynamicGrouper.cs index 11c2d359..cb488019 100644 --- a/src/DynamicData/Cache/Internal/DynamicGrouper.cs +++ b/src/DynamicData/Cache/Internal/DynamicGrouper.cs @@ -143,7 +143,7 @@ public void Initialize(IEnumerable> initialValues, F EmitChanges(observer); } - public bool EmitChanges(IObserver> observer) + public void EmitChanges(IObserver> observer) { // Verify logic doesn't capture any non-empty groups Debug.Assert(_emptyGroups.All(static group => group.Cache.Count == 0), "Non empty Group in Empty Group HashSet"); @@ -170,10 +170,7 @@ public bool EmitChanges(IObserver> obs if (changeSet.Count != 0) { observer.OnNext(new GroupChangeSet(changeSet)); - return true; } - - return false; } public void Dispose() diff --git a/src/DynamicData/Cache/Internal/GroupOnObservable.cs b/src/DynamicData/Cache/Internal/GroupOnObservable.cs index 84d75be2..5a2219f7 100644 --- a/src/DynamicData/Cache/Internal/GroupOnObservable.cs +++ b/src/DynamicData/Cache/Internal/GroupOnObservable.cs @@ -37,10 +37,5 @@ public IObservable> Run() => } }, onInner: (value, parentKey) => grouper.AddOrUpdate(parentKey, value.GroupKey, value.Item), - onDrainComplete: observer => - { - while (grouper.EmitChanges(observer)) - { - } - })); + onDrainComplete: observer => grouper.EmitChanges(observer))); } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs index 73707f08..1a8b982c 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs @@ -63,14 +63,9 @@ private sealed class ChangesOrchestrator( public override void OnDrainComplete(bool sourcesCompleted) { - while (true) + var captured = _cache.CaptureChanges(); + if (captured.Count != 0) { - var captured = _cache.CaptureChanges(); - if (captured.Count == 0) - { - break; - } - Emitter.OnNext(captured); } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs index 73338677..4e416abf 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs @@ -65,16 +65,7 @@ public MergedOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete(bool sourcesCompleted) - { - // Loop until tracker is fully drained. Each Emitter.OnNext triggers a reentrant drain - // that may deliver concurrent inner emissions into the tracker without firing - // OnDrainComplete for them; without this loop those items would sit in the tracker - // until another source/inner emission triggers a future drain. - while (_tracker.EmitChanges(Emitter)) - { - } - } + public override void OnDrainComplete(bool sourcesCompleted) => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs index 413ed5c7..44248565 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs @@ -55,12 +55,7 @@ public MergedListOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete(bool sourcesCompleted) - { - while (_tracker.EmitChanges(Emitter)) - { - } - } + public override void OnDrainComplete(bool sourcesCompleted) => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index 95bbfbdf..f58b2e75 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -45,12 +45,7 @@ public Orchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete(bool sourcesCompleted) - { - while (_tracker.EmitChanges(Emitter)) - { - } - } + public override void OnDrainComplete(bool sourcesCompleted) => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/TransformOnObservable.cs b/src/DynamicData/Cache/Internal/TransformOnObservable.cs index a10bb46a..19f8d29b 100644 --- a/src/DynamicData/Cache/Internal/TransformOnObservable.cs +++ b/src/DynamicData/Cache/Internal/TransformOnObservable.cs @@ -50,14 +50,9 @@ public IObservable> Run() => Observable.Defer(() onInner: (value, key) => cache.AddOrUpdate(value, key), onDrainComplete: observer => { - while (true) + var captured = cache.CaptureChanges(); + if (captured.Count != 0) { - var captured = cache.CaptureChanges(); - if (captured.Count == 0) - { - break; - } - observer.OnNext(captured); } }); diff --git a/src/DynamicData/Internal/SharedDeliveryQueue.cs b/src/DynamicData/Internal/SharedDeliveryQueue.cs index 6eab2889..b46cc865 100644 --- a/src/DynamicData/Internal/SharedDeliveryQueue.cs +++ b/src/DynamicData/Internal/SharedDeliveryQueue.cs @@ -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. @@ -157,6 +158,9 @@ internal void ExitLockAndDrain() if (_drainThreadId == currentThreadId) { ExitLock(); + // Flag the reentrancy so the outer DrainAll re-fires _onDrainComplete + // for orchestrators that accumulate state during this reentrant drain. + _drainReentered = true; DrainPending(); return; } @@ -198,21 +202,22 @@ private void DrainAll() return; } + // Reset before the callback so the flag exclusively reflects reentrant + // drains triggered by _onDrainComplete itself. + _drainReentered = false; + if (_onDrainComplete is not null) { _onDrainComplete(); } - // 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 if a reentrant drain ran during + // _onDrainComplete (items consumed by the reentrant drain may have + // updated orchestrator state that needs another flush pass). 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/ChangeSetMergeTracker.cs b/src/DynamicData/List/Internal/ChangeSetMergeTracker.cs index af93b8aa..fa2e2cec 100644 --- a/src/DynamicData/List/Internal/ChangeSetMergeTracker.cs +++ b/src/DynamicData/List/Internal/ChangeSetMergeTracker.cs @@ -65,16 +65,13 @@ public void RemoveItems(IEnumerable removeItems, IObserver> observer) + public void EmitChanges(IObserver> observer) { var changeSet = _resultList.CaptureChanges(); if (changeSet.Count != 0) { observer.OnNext(changeSet); - return true; } - - return false; } private void OnClear(Change change) => _resultList.ClearOrRemoveMany(change); From 52a4606325ac3632f1f7fd0b4edf9a03bb5df5aa Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 8 Jun 2026 06:36:57 -0700 Subject: [PATCH 18/30] Rename OnDrainComplete parameter to isFinal; add contract + queue tests Rename: OnDrainComplete(bool sourcesCompleted) -> OnDrainComplete(bool isFinal). The new name is action-oriented (signals to the orchestrator that this is the last chance to emit before downstream OnCompleted) rather than implementation-coupled (the old name referenced the runtime's _isCompleted latch mechanism). XML doc updated to make the meaning explicit. Add two missing contract tests: 1. OrchestrateManyFixture.OnDrainComplete_IsFinalIsFalseUntilSourceAndAllInnersComplete - Verifies isFinal=false on every call while source and tracked inners are still active, that source completion alone does not flip isFinal while an inner subscription remains, and that isFinal=true on at least one call after the last inner completes (followed by downstream OnCompleted). 2. SharedDeliveryQueueFixture.OnDrainComplete_RefiresAfterReentrantDrainTriggeredByCallback - Focused regression test for the queue-level C1 fix: when the onDrainComplete callback enqueues an item onto a sub-queue, the reentrant drain processes the item but the outer DrainAll must re-fire onDrainComplete because of the _drainReentered flag. Red-green verified: fails when _drainReentered check is removed; passes when restored. Tests: 2407/2407 pass (+2 new). TransformManyAsync flake check 30/30 pass. --- .../Internal/OrchestrateManyFixture.cs | 38 +++++++++++++++- .../Internal/SharedDeliveryQueueFixture.cs | 44 +++++++++++++++++++ src/DynamicData/Cache/Internal/AutoRefresh.cs | 8 ++-- .../Cache/Internal/ICacheOrchestrator.cs | 12 ++--- .../IntObservableCacheEx.OrchestrateMany.cs | 2 +- ...bservableCacheEx.OrchestrateManyChanges.cs | 2 +- ...ObservableCacheEx.OrchestrateManyMerged.cs | 2 +- ...rvableCacheEx.OrchestrateManyMergedList.cs | 2 +- .../Cache/Internal/Orchestration.cs | 4 +- .../Internal/OrchestratorCacheChangeBase.cs | 2 +- .../Cache/Internal/TransformManyAsync.cs | 2 +- 11 files changed, 99 insertions(+), 19 deletions(-) diff --git a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs index 3488ee25..f25edc77 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs @@ -183,6 +183,39 @@ public void Completion_ParentOnly_NoChildren() 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); + + // Activity while source + inner are alive + 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"); + + // Source completes; inner still alive — isFinal must remain false + 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"); + + // Final inner completes — at least one subsequent OnDrainComplete must observe isFinal=true + 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() { @@ -489,6 +522,7 @@ private sealed class TestOrchestrator( public int ParentCallCount; public int EmitCallCount; public readonly List<(string Value, int Key)> ChildCalls = []; + public readonly List IsFinalLog = []; public void OnSourceChangeSet(IChangeSet changes) { @@ -515,8 +549,10 @@ public void OnInner(string child, int parentKey) _cache.AddOrUpdate(new TestItem(parentKey, child), parentKey); } - public void OnDrainComplete(bool sourcesCompleted) + public void OnDrainComplete(bool isFinal) { + IsFinalLog.Add(isFinal); + var changes = _cache.CaptureChanges(); if (changes.Count > 0) { diff --git a/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs b/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs index e67444cb..7943aa60 100644 --- a/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs +++ b/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs @@ -175,6 +175,50 @@ public async Task ConcurrentMultiSourceDelivery() } } + /// + /// Regression test for the C1 fix: when _onDrainComplete enqueues an item onto a + /// sub-queue (modeling an orchestrator emitting accumulated state), the reentrant drain + /// inside ExitLockAndDrain processes the item but leaves _activeBits empty. + /// Without the fix, the outer DrainAll exits without re-firing _onDrainComplete; + /// any state the orchestrator accumulates during the reentrant drain is then stranded until + /// the next unrelated drain. The fix flags drain reentrancy and loops _onDrainComplete + /// when the flag is set, even when _activeBits shows nothing pending. + /// + [Fact] + public void OnDrainComplete_RefiresAfterReentrantDrainTriggeredByCallback() + { + var callCount = 0; + var delivered = new List(); + DeliverySubQueue? sub = null; + + SharedDeliveryQueue queue = null!; + queue = new SharedDeliveryQueue(onDrainComplete: () => + { + // Emit one item on the first callback invocation only. The enqueue happens while we + // are inside DrainAll on the drain thread, so ExitLockAndDrain takes the reentrant + // path and consumes the item without setting _activeBits. The outer DrainAll must + // still re-fire OnDrainComplete because of the reentrancy flag. + if (Interlocked.Increment(ref callCount) == 1) + { + using var scope = sub!.AcquireLock(); + scope.EnqueueNext(42); + } + }); + + var observer = new TestObserver(delivered.Add); + sub = queue.CreateQueue(observer); + + // Prime the drain with an initial item so DrainAll fires onDrainComplete at least once. + 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/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index d644e54f..299fe9ce 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -74,15 +74,15 @@ public override void OnInner(Change refresh, TKey key) } } - public override void OnDrainComplete(bool sourcesCompleted) + public override void OnDrainComplete(bool isFinal) { _sourceTouched.Clear(); // Flush pending refreshes whenever there is no timer-based deferral active - // (unbuffered) or when sources have completed (so the timer would never fire - // before downstream completion). The queue re-fires OnDrainComplete after any + // (unbuffered) or when this is the final drain (the timer would otherwise be + // cancelled by stream termination). The queue re-fires OnDrainComplete after any // reentrant drain triggered by FlushPending, so a single emit per call suffices. - if (sourcesCompleted || buffer is null) + if (isFinal || buffer is null) { FlushPending(); } diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs index 81781bd7..6d2ecc3f 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs @@ -39,11 +39,11 @@ internal interface ICacheOrchestrator /// 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 the source changeset and every tracked inner observable have all - /// completed, signalling that this is the last opportunity to emit before the downstream - /// observer receives OnCompleted. Implementations holding deferred state (timer-armed - /// buffers, debounced batches) should flush synchronously when this is . + /// + /// 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 . /// - void OnDrainComplete(bool sourcesCompleted); + void OnDrainComplete(bool isFinal); } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs index e965ae1b..9ee1a673 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs @@ -74,6 +74,6 @@ private sealed class LambdaCacheOrchestrator( public void OnInner(TInner value, TKey key) => onInner(value, key); - public void OnDrainComplete(bool sourcesCompleted) => onDrainComplete(emitter); + public void OnDrainComplete(bool isFinal) => onDrainComplete(emitter); } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs index 1a8b982c..d12f4fd1 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs @@ -61,7 +61,7 @@ private sealed class ChangesOrchestrator( public override void OnInner((TSource Item, TInner Value) value, TKey key) => onInner(_cache, key, value.Item, value.Value); - public override void OnDrainComplete(bool sourcesCompleted) + public override void OnDrainComplete(bool isFinal) { var captured = _cache.CaptureChanges(); if (captured.Count != 0) diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs index 4e416abf..8a4c6517 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs @@ -65,7 +65,7 @@ public MergedOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete(bool sourcesCompleted) => _tracker.EmitChanges(Emitter); + public override void OnDrainComplete(bool isFinal) => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs index 44248565..aba4dd35 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs @@ -55,7 +55,7 @@ public MergedListOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete(bool sourcesCompleted) => _tracker.EmitChanges(Emitter); + public override void OnDrainComplete(bool isFinal) => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); diff --git a/src/DynamicData/Cache/Internal/Orchestration.cs b/src/DynamicData/Cache/Internal/Orchestration.cs index 66ac782e..932e4edc 100644 --- a/src/DynamicData/Cache/Internal/Orchestration.cs +++ b/src/DynamicData/Cache/Internal/Orchestration.cs @@ -138,11 +138,11 @@ private void OnDrainComplete() // own emit can land here recursively, latch _isCompleted off, and complete the stream // synchronously. When control returns to the outer call, the snapshot still tells the // orchestrator that source and tracked inners are done. - var sourcesCompleted = Volatile.Read(ref _isCompleted); + var isFinal = Volatile.Read(ref _isCompleted); try { - _orchestrator.OnDrainComplete(sourcesCompleted); + _orchestrator.OnDrainComplete(isFinal); } catch (Exception error) { diff --git a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs index 8e67217f..329f75f0 100644 --- a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs +++ b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs @@ -55,7 +55,7 @@ public virtual void OnSourceChangeSet(IChangeSet changes) public abstract void OnInner(TInner value, TKey key); - public virtual void OnDrainComplete(bool sourcesCompleted) + public virtual void OnDrainComplete(bool isFinal) { } diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index f58b2e75..3216691a 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -45,7 +45,7 @@ public Orchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete(bool sourcesCompleted) => _tracker.EmitChanges(Emitter); + public override void OnDrainComplete(bool isFinal) => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); From 31da024f937065a820959dc1a3757997720d6c46 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Thu, 11 Jun 2026 08:55:28 -0700 Subject: [PATCH 19/30] Orchestrator: correctness, ergonomics, and devirtualization improvements Bundles eight design fixes from the orchestrator review: (1+2) Counter-based final check + CAS-latched completion: OnDrainComplete derives isFinal from the live subscription counter rather than a separate _isCompleted latch, and uses a one-shot CAS on _completionEmitted to guarantee exactly-one OnCompleted. Fixes a race where an orchestrator that called Track during OnDrainComplete(isFinal=true) would have the new inner's emissions silently dropped because the latch stayed set. (3) Factory exception safety: the factory call is wrapped in try/catch that disposes the already-allocated queue and emitter before rethrowing, so a faulting orchestrator constructor doesn't leak the runtime's resources. (4) Disposal order: source and inner subscriptions are disposed before the queue, so source pumps cannot keep firing into a terminating queue. (8) Documented Serialize lifetime semantics: a remarks block on ICacheOrchestratorContext.Serialize calls out explicitly that Serialize does NOT participate in completion accounting (unlike Track), preventing future TrackAuxiliary-style confusion. (9) Generic devirtualization: Orchestration and OrchestrateMany take a TOrch type parameter so dispatch sites (OnSourceChangeSet, OnInner, OnDrainComplete) call the concrete sealed class through a non-interface field and the JIT can devirtualize. All internal call sites updated to specify TOrch explicitly (older C# language modes can't infer it from the lambda body). (11) Track + Untrack split: the nullable observable parameter on Track is gone; Untrack(key) is now the explicit way to remove a registration. KeyedDisposable.Add doc clarified (Add has replace-or-add semantics). (13) wasReentrant flag on OnDrainComplete: the SharedDeliveryQueue callback now passes the captured _drainReentered flag through, and ICacheOrchestrator.OnDrainComplete(bool isFinal, bool wasReentrant) exposes it for advanced consumers. Most orchestrators ignore it. (14) Named SourceSubscriptionWeight constant replaces the magic 1 in _subscriptionCounter initialization. API rename: the 3-lambda convenience overload is now OrchestrateLambdas (not OrchestrateMany) to avoid generic inference conflict with the factory overload. GroupOnObservable and TransformOnObservable updated accordingly. Tests: 2407/2407 pass. --- .../Internal/OrchestrateManyFixture.cs | 16 ++-- .../Internal/SharedDeliveryQueueFixture.cs | 2 +- src/DynamicData/Cache/Internal/AutoRefresh.cs | 6 +- .../Cache/Internal/GroupOnObservable.cs | 6 +- .../Cache/Internal/ICacheOrchestrator.cs | 8 +- .../Internal/ICacheOrchestratorContext.cs | 31 +++++-- .../IntObservableCacheEx.OrchestrateMany.cs | 32 ++++--- ...bservableCacheEx.OrchestrateManyChanges.cs | 6 +- ...ObservableCacheEx.OrchestrateManyMerged.cs | 6 +- ...rvableCacheEx.OrchestrateManyMergedList.cs | 6 +- src/DynamicData/Cache/Internal/MergeMany.cs | 4 +- .../Cache/Internal/MergeManyItems.cs | 4 +- .../Cache/Internal/Orchestration.cs | 85 ++++++++++++------- .../Internal/OrchestratorCacheChangeBase.cs | 2 +- .../Cache/Internal/TransformManyAsync.cs | 6 +- .../Cache/Internal/TransformOnObservable.cs | 6 +- src/DynamicData/Internal/KeyedDisposable.cs | 10 ++- .../Internal/SharedDeliveryQueue.cs | 21 +++-- 18 files changed, 158 insertions(+), 99 deletions(-) diff --git a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs index f25edc77..abbd1abd 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs @@ -52,7 +52,7 @@ private static (IObservable> Observable, Func>( + var observable = source.OrchestrateMany, TestOrchestrator>( (ctx, em) => captured = new TestOrchestrator(ctx, em, childFactory, onParent, onChild)); return (observable, () => captured ?? throw new InvalidOperationException("Subscribe to the returned observable first.")); } @@ -456,10 +456,10 @@ public void LambdaOverload_MultipleSubscriptions_DoNotShareOrchestrator() var emitCalls = 0; var contexts = new List(); - // Build a chain that captures whichever context (via the track callback's identity) the - // orchestrator received in Initialize. Two subscribers should each see their own context. - var observable = source.Connect().OrchestrateMany( - onSourceChangeSet: (changes, track) => + // Build a chain that captures whichever context (via the track callback's identity) each + // orchestrator received. Two subscribers should each see their own context. + var observable = source.Connect().OrchestrateLambdas( + onSourceChangeSet: (changes, track, untrack) => { // Capture an identity-stable token for the track delegate, proving each subscription // has its own context. Hash code of the bound delegate target reflects the underlying @@ -523,6 +523,7 @@ private sealed class TestOrchestrator( public int EmitCallCount; public readonly List<(string Value, int Key)> ChildCalls = []; public readonly List IsFinalLog = []; + public readonly List WasReentrantLog = []; public void OnSourceChangeSet(IChangeSet changes) { @@ -537,7 +538,7 @@ public void OnSourceChangeSet(IChangeSet 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.Track(change.Key, null); + context.Untrack(change.Key); } } } @@ -549,9 +550,10 @@ public void OnInner(string child, int parentKey) _cache.AddOrUpdate(new TestItem(parentKey, child), parentKey); } - public void OnDrainComplete(bool isFinal) + public void OnDrainComplete(bool isFinal, bool wasReentrant) { IsFinalLog.Add(isFinal); + WasReentrantLog.Add(wasReentrant); var changes = _cache.CaptureChanges(); if (changes.Count > 0) diff --git a/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs b/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs index 7943aa60..3abfea4e 100644 --- a/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs +++ b/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs @@ -192,7 +192,7 @@ public void OnDrainComplete_RefiresAfterReentrantDrainTriggeredByCallback() DeliverySubQueue? sub = null; SharedDeliveryQueue queue = null!; - queue = new SharedDeliveryQueue(onDrainComplete: () => + queue = new SharedDeliveryQueue(onDrainComplete: _ => { // Emit one item on the first callback invocation only. The enqueue happens while we // are inside DrainAll on the drain thread, so ExitLockAndDrain takes the reentrant diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index 299fe9ce..4ebbc093 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -21,7 +21,7 @@ internal sealed class AutoRefresh( public IObservable> Run() { var sched = buffer is null ? null : scheduler ?? GlobalConfig.DefaultScheduler; - return source.OrchestrateMany, IChangeSet>( + return source.OrchestrateMany, IChangeSet, Orchestrator>( (context, emitter) => new Orchestrator(context, emitter, reEvaluator, buffer, sched)); } @@ -74,7 +74,7 @@ public override void OnInner(Change refresh, TKey key) } } - public override void OnDrainComplete(bool isFinal) + public override void OnDrainComplete(bool isFinal, bool wasReentrant) { _sourceTouched.Clear(); @@ -104,7 +104,7 @@ protected override void OnItemRemoved(TObject item, TKey key) { _sourceTouched.Add(key); DropPending(key); - Context.Track(key, null); + Context.Untrack(key); } protected override void OnItemRefreshed(TObject item, TKey key) => _sourceTouched.Add(key); diff --git a/src/DynamicData/Cache/Internal/GroupOnObservable.cs b/src/DynamicData/Cache/Internal/GroupOnObservable.cs index 5a2219f7..7af358e2 100644 --- a/src/DynamicData/Cache/Internal/GroupOnObservable.cs +++ b/src/DynamicData/Cache/Internal/GroupOnObservable.cs @@ -15,8 +15,8 @@ internal sealed class GroupOnObservable(IObservable> Run() => Observable.Using( resourceFactory: () => new DynamicGrouper(), - observableFactory: grouper => source.OrchestrateMany>( - onSourceChangeSet: (changes, track) => + observableFactory: grouper => source.OrchestrateLambdas>( + onSourceChangeSet: (changes, track, untrack) => { foreach (var change in changes.ToConcreteType()) { @@ -31,7 +31,7 @@ public IObservable> Run() => break; case ChangeReason.Remove: - track(change.Key, null); + untrack(change.Key); break; } } diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs index 6d2ecc3f..dda185c1 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs @@ -45,5 +45,11 @@ internal interface ICacheOrchestrator /// completed). Implementations holding deferred state (timer-armed buffers, debounced batches) /// should flush synchronously when this is . /// - void OnDrainComplete(bool isFinal); + /// + /// when a reentrant drain occurred during the prior delivery cycle (an + /// orchestrator emit triggered same-thread re-entry into the queue's drain loop). Most + /// orchestrators can ignore this; it is exposed for advanced consumers that want to differentiate + /// the "I just emitted" path from a clean drain cycle. + /// + void OnDrainComplete(bool isFinal, bool wasReentrant); } diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs index 4cefd1fb..404c91c9 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs @@ -6,9 +6,8 @@ namespace DynamicData.Cache.Internal; /// /// Runtime context exposed to an -/// via . Provides per-key -/// inner observable lifecycle management and a hook to serialize arbitrary observables through the -/// same shared queue as source and inner notifications. +/// 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. @@ -17,13 +16,21 @@ internal interface ICacheOrchestratorContext where TInner : notnull { /// - /// Registers, replaces, or removes the inner observable associated with . - /// Pass a non- observable to register or replace; pass - /// to remove. The supplied observable is automatically routed through the shared delivery queue. + /// 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 change. - /// The new inner observable, or to remove. - void Track(TKey key, IObservable? observable); + /// 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 delivery queue's synchronization gate so @@ -31,6 +38,12 @@ internal interface ICacheOrchestratorContext /// buffering of values that will be re-emitted) run under the same serialization that source /// and inner notifications already enjoy. /// + /// + /// Unlike , a Serialize-wrapped subscription does NOT participate in + /// completion accounting. The downstream stream can complete even while a Serialize-wrapped + /// subscription is still active. Use for subscriptions whose lifetime + /// should keep the stream alive. + /// /// Value type of the observable being serialized. /// The observable to wrap. /// A new observable whose OnNext delivery runs under the orchestrator's queue lock. diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs index 9ee1a673..e5933bb5 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs @@ -22,47 +22,53 @@ internal static partial class IntObservableCacheEx /// 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 over the concrete type so dispatch + /// sites devirtualize. C# generic inference resolves this from the factory's return type. + /// /// 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 OrchestrateMany( + public static IObservable OrchestrateMany( this IObservable> source, - Func, IObserver, ICacheOrchestrator> factory) + Func, IObserver, TOrch> factory) where TSource : notnull where TKey : notnull - where TInner : notnull => - new Orchestration(source, factory).Run(); + where TInner : notnull + where TOrch : ICacheOrchestrator => + new Orchestration(source, factory).Run(); /// - /// Convenience overload that wraps three lambdas into an - /// and delegates to . + /// Convenience method that wraps three lambdas into an . /// Does not expose ; operators that /// need it must implement directly. + /// The lambdas may register inner subscriptions via the track callback (paired with + /// untrack for removal). /// /// 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 by . /// The keyed source changeset stream. - /// Invoked for each source changeset, paired with a track callback. + /// Invoked for each source changeset, paired with track and untrack callbacks. /// Invoked for each value emitted by a tracked inner observable, paired with its key. /// Invoked once per drain cycle to flush the aggregated state to the emitter. /// An observable that orchestrates source and inner activity into a single result stream. - public static IObservable OrchestrateMany( + public static IObservable OrchestrateLambdas( this IObservable> source, - Action, Action?>> onSourceChangeSet, + Action, Action>, Action> onSourceChangeSet, Action onInner, Action> onDrainComplete) where TSource : notnull where TKey : notnull where TInner : notnull => - source.OrchestrateMany( + source.OrchestrateMany>( (context, emitter) => new LambdaCacheOrchestrator(context, emitter, onSourceChangeSet, onInner, onDrainComplete)); private sealed class LambdaCacheOrchestrator( ICacheOrchestratorContext context, IObserver emitter, - Action, Action?>> onSourceChangeSet, + Action, Action>, Action> onSourceChangeSet, Action onInner, Action> onDrainComplete) : ICacheOrchestrator @@ -70,10 +76,10 @@ private sealed class LambdaCacheOrchestrator( where TKey : notnull where TInner : notnull { - public void OnSourceChangeSet(IChangeSet changes) => onSourceChangeSet(changes, context.Track); + public void OnSourceChangeSet(IChangeSet changes) => onSourceChangeSet(changes, context.Track, context.Untrack); public void OnInner(TInner value, TKey key) => onInner(value, key); - public void OnDrainComplete(bool isFinal) => onDrainComplete(emitter); + public void OnDrainComplete(bool isFinal, bool wasReentrant) => onDrainComplete(emitter); } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs index d12f4fd1..24294a1f 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs @@ -42,7 +42,7 @@ public static IObservable> OrchestrateManyChanges - source.OrchestrateMany>( + source.OrchestrateMany, ChangesOrchestrator>( (context, emitter) => new ChangesOrchestrator(context, emitter, innerFactory, onSourceChange, onInner)); private sealed class ChangesOrchestrator( @@ -61,7 +61,7 @@ private sealed class ChangesOrchestrator( public override void OnInner((TSource Item, TInner Value) value, TKey key) => onInner(_cache, key, value.Item, value.Value); - public override void OnDrainComplete(bool isFinal) + public override void OnDrainComplete(bool isFinal, bool wasReentrant) { var captured = _cache.CaptureChanges(); if (captured.Count != 0) @@ -85,7 +85,7 @@ protected override void OnItemUpdated(TSource current, TSource previous, TKey ke protected override void OnItemRemoved(TSource item, TKey key) { onSourceChange(_cache, new Change(ChangeReason.Remove, key, item)); - Context.Track(key, null); + Context.Untrack(key); } protected override void OnItemRefreshed(TSource item, TKey key) => diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs index 8a4c6517..5a5b08c7 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs @@ -35,7 +35,7 @@ public static IObservable> OrchestrateManyMerged - source.OrchestrateMany, IChangeSet>( + source.OrchestrateMany, IChangeSet, MergedOrchestrator>( (context, emitter) => new MergedOrchestrator(context, emitter, changeSetSelector, equalityComparer, comparer, reevalOnRefresh)); private sealed class MergedOrchestrator : OrchestratorCacheChangeBase, IChangeSet> @@ -65,7 +65,7 @@ public MergedOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete(bool isFinal) => _tracker.EmitChanges(Emitter); + public override void OnDrainComplete(bool isFinal, bool wasReentrant) => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); @@ -89,7 +89,7 @@ protected override void OnItemRemoved(TSource item, TKey key) _tracker.RemoveItems(removed.Value.Cache.KeyValues); } - Context.Track(key, null); + Context.Untrack(key); } protected override void OnItemRefreshed(TSource item, TKey key) diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs index aba4dd35..02b6f68e 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs @@ -29,7 +29,7 @@ public static IObservable> OrchestrateManyMergedList - source.OrchestrateMany, IChangeSet>( + source.OrchestrateMany, IChangeSet, MergedListOrchestrator>( (context, emitter) => new MergedListOrchestrator(context, emitter, changeSetSelector, equalityComparer)); private sealed class MergedListOrchestrator : OrchestratorCacheChangeBase, IChangeSet> @@ -55,7 +55,7 @@ public MergedListOrchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete(bool isFinal) => _tracker.EmitChanges(Emitter); + public override void OnDrainComplete(bool isFinal, bool wasReentrant) => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); @@ -78,7 +78,7 @@ protected override void OnItemRemoved(TSource item, TKey key) _tracker.RemoveItems(removed.List); } - Context.Track(key, null); + Context.Untrack(key); } private void SubscribeChild(TSource item, TKey key) diff --git a/src/DynamicData/Cache/Internal/MergeMany.cs b/src/DynamicData/Cache/Internal/MergeMany.cs index 59d1c50f..a329a728 100644 --- a/src/DynamicData/Cache/Internal/MergeMany.cs +++ b/src/DynamicData/Cache/Internal/MergeMany.cs @@ -28,7 +28,7 @@ public MergeMany(IObservable> source, Func Run() => - _source.OrchestrateMany( + _source.OrchestrateMany( (context, emitter) => new Orchestrator(context, emitter, _observableSelector)); private sealed class Orchestrator( @@ -41,6 +41,6 @@ private sealed class Orchestrator( protected override void OnItemAdded(TObject item, TKey key) => Context.Track(key, selector(item, key)); - protected override void OnItemRemoved(TObject item, TKey key) => Context.Track(key, null); + protected override void OnItemRemoved(TObject item, TKey key) => Context.Untrack(key); } } diff --git a/src/DynamicData/Cache/Internal/MergeManyItems.cs b/src/DynamicData/Cache/Internal/MergeManyItems.cs index 97316720..7ee94a5a 100644 --- a/src/DynamicData/Cache/Internal/MergeManyItems.cs +++ b/src/DynamicData/Cache/Internal/MergeManyItems.cs @@ -28,7 +28,7 @@ public MergeManyItems(IObservable> source, Func> Run() => - _source.OrchestrateMany>( + _source.OrchestrateMany, Orchestrator>( (context, emitter) => new Orchestrator(context, emitter, _observableSelector)); private sealed class Orchestrator( @@ -43,6 +43,6 @@ public override void OnInner((TObject Item, TDestination Value) value, TKey key) protected override void OnItemAdded(TObject item, TKey key) => Context.Track(key, selector(item, key).Select(value => (Item: item, Value: value))); - protected override void OnItemRemoved(TObject item, TKey key) => Context.Track(key, null); + protected override void OnItemRemoved(TObject item, TKey key) => Context.Untrack(key); } } diff --git a/src/DynamicData/Cache/Internal/Orchestration.cs b/src/DynamicData/Cache/Internal/Orchestration.cs index 932e4edc..1b62ccbf 100644 --- a/src/DynamicData/Cache/Internal/Orchestration.cs +++ b/src/DynamicData/Cache/Internal/Orchestration.cs @@ -22,32 +22,41 @@ namespace DynamicData.Cache.Internal; /// 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 Orchestration( +internal sealed class Orchestration( IObservable> source, - Func, IObserver, ICacheOrchestrator> factory) + 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 { + /// + /// Initial counter value representing the source subscription itself. Source completion + /// decrements by this amount; tracked inner subscriptions add their own +1 each. + /// + private const int SourceSubscriptionWeight = 1; + private readonly KeyedDisposable _innerSubscriptions = new(); private readonly SingleAssignmentDisposable _sourceSubscription = new(); private readonly SharedDeliveryQueue _queue; private readonly DeliverySubQueue _emitter; - private readonly ICacheOrchestrator _orchestrator; - private int _subscriptionCounter = 1; - private bool _isCompleted; + private readonly TOrch _orchestrator = default!; + private int _subscriptionCounter = SourceSubscriptionWeight; + private int _completionEmitted; private bool _disposed; public OrchestratorContext( IObservable> source, IObserver observer, - Func, IObserver, ICacheOrchestrator> factory) + Func, IObserver, TOrch> factory) { _queue = new SharedDeliveryQueue(onDrainComplete: OnDrainComplete); @@ -55,7 +64,20 @@ public OrchestratorContext( // sync inner emissions (higher index) deliver first and any orchestrator emit lands on the // emitter after that work has settled. _emitter = _queue.CreateQueue(observer); - _orchestrator = factory(this, _emitter); + + // Wrap the factory call so a throw is reported via the standard error channel and any + // queue/emitter we already allocated are released. + try + { + _orchestrator = factory(this, _emitter); + Debug.Assert(_orchestrator is not null, "Factory must not return null"); + } + catch + { + _queue.Dispose(); + _emitter.Dispose(); + throw; + } _sourceSubscription.Disposable = source .SynchronizeSafe(_queue) @@ -73,20 +95,19 @@ public void Dispose() } _disposed = true; - _queue.Dispose(); + + // Stop incoming work first so source/inner pumps cannot keep firing into a terminating + // queue. The queue itself is disposed afterwards to drain (or terminate) cleanly. _sourceSubscription.Dispose(); _innerSubscriptions.Dispose(); + _queue.Dispose(); _emitter.Dispose(); (_orchestrator as IDisposable)?.Dispose(); } - public void Track(TKey key, IObservable? observable) + public void Track(TKey key, IObservable observable) { - if (observable is null) - { - _innerSubscriptions.Remove(key); - return; - } + Debug.Assert(observable is not null, "Use Untrack(key) to remove a tracked subscription"); // Increment before adding so the OnCompleted callback that fires when the previous subscription // for this key is disposed does not race the counter down to zero and signal premature termination. @@ -97,7 +118,7 @@ public void Track(TKey key, IObservable? observable) // Finally(DecrementSubscriptionCount) fires on completion, error, AND disposal, so the counter // always decrements. The onCompleted callback only fires on normal completion, so an inner // subscription disposed by Track replacing it (or by Dispose) does not trigger Remove from inside. - container.Disposable = observable + container.Disposable = observable! .SynchronizeSafe(_queue) .Finally(DecrementSubscriptionCount) .SubscribeSafe( @@ -106,6 +127,8 @@ public void Track(TKey key, IObservable? observable) 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) @@ -132,17 +155,18 @@ private void OnInner(TInner value, TKey key) } } - private void OnDrainComplete() + private void OnDrainComplete(bool wasReentrant) { - // Snapshot before calling the orchestrator: a reentrant drain triggered by the orchestrator's - // own emit can land here recursively, latch _isCompleted off, and complete the stream - // synchronously. When control returns to the outer call, the snapshot still tells the - // orchestrator that source and tracked inners are done. - var isFinal = Volatile.Read(ref _isCompleted); + // Counter == 0 means source and every tracked inner have terminated. This is the + // authoritative source of truth for "this is the last drain"; using a separate latched + // flag races when the orchestrator calls Track during the isFinal call (counter goes + // back to 1 but a latched flag would still say true, and we'd fire OnCompleted while + // a live inner exists). + var isFinal = Volatile.Read(ref _subscriptionCounter) == 0; try { - _orchestrator.OnDrainComplete(isFinal); + _orchestrator.OnDrainComplete(isFinal, wasReentrant); } catch (Exception error) { @@ -150,23 +174,20 @@ private void OnDrainComplete() return; } - if (Volatile.Read(ref _isCompleted)) + // Re-check the counter: if the orchestrator added a tracked subscription during its + // OnDrainComplete (re-establishing liveness), do not complete. CAS-latch ensures + // exactly one OnCompleted across any number of repeated drains seeing counter == 0. + if (Volatile.Read(ref _subscriptionCounter) == 0 && + Interlocked.CompareExchange(ref _completionEmitted, 1, 0) == 0) { - // Latch off so the inevitable reentrant drain (triggered by enqueueing OnCompleted on - // the emitter sub-queue) doesn't try to complete the stream twice. - Volatile.Write(ref _isCompleted, false); _emitter.OnCompleted(); } } private void DecrementSubscriptionCount() { - if (Interlocked.Decrement(ref _subscriptionCounter) == 0) - { - Volatile.Write(ref _isCompleted, true); - } - - Debug.Assert(_subscriptionCounter >= 0, "Should never be negative"); + var remaining = Interlocked.Decrement(ref _subscriptionCounter); + Debug.Assert(remaining >= 0, "Subscription counter should never go negative"); } } } diff --git a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs index 329f75f0..dcab297e 100644 --- a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs +++ b/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs @@ -55,7 +55,7 @@ public virtual void OnSourceChangeSet(IChangeSet changes) public abstract void OnInner(TInner value, TKey key); - public virtual void OnDrainComplete(bool isFinal) + public virtual void OnDrainComplete(bool isFinal, bool wasReentrant) { } diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index 3216691a..9fcb15a0 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -19,7 +19,7 @@ internal sealed class TransformManyAsync> Run() => - source.OrchestrateMany, IChangeSet>( + source.OrchestrateMany, IChangeSet, Orchestrator>( (context, emitter) => new Orchestrator(context, emitter, transformer, equalityComparer, comparer, errorHandler)); private sealed class Orchestrator : OrchestratorCacheChangeBase, IChangeSet> @@ -45,7 +45,7 @@ public Orchestrator( public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); - public override void OnDrainComplete(bool isFinal) => _tracker.EmitChanges(Emitter); + public override void OnDrainComplete(bool isFinal, bool wasReentrant) => _tracker.EmitChanges(Emitter); protected override void OnItemAdded(TSource item, TKey key) => SubscribeChild(item, key); @@ -67,7 +67,7 @@ protected override void OnItemRemoved(TSource item, TKey key) _tracker.RemoveItems(removed.Value.Cache.KeyValues); } - Context.Track(key, null); + Context.Untrack(key); } private void SubscribeChild(TSource item, TKey key) diff --git a/src/DynamicData/Cache/Internal/TransformOnObservable.cs b/src/DynamicData/Cache/Internal/TransformOnObservable.cs index 19f8d29b..6ef32d3b 100644 --- a/src/DynamicData/Cache/Internal/TransformOnObservable.cs +++ b/src/DynamicData/Cache/Internal/TransformOnObservable.cs @@ -16,8 +16,8 @@ public IObservable> Run() => Observable.Defer(() { var cache = new ChangeAwareCache(); - return source.OrchestrateMany>( - onSourceChangeSet: (changes, track) => + return source.OrchestrateLambdas>( + onSourceChangeSet: (changes, track, untrack) => { foreach (var change in changes.ToConcreteType()) { @@ -29,7 +29,7 @@ public IObservable> Run() => Observable.Defer(() case ChangeReason.Remove: cache.Remove(change.Key); - track(change.Key, null); + untrack(change.Key); break; case ChangeReason.Refresh: diff --git a/src/DynamicData/Internal/KeyedDisposable.cs b/src/DynamicData/Internal/KeyedDisposable.cs index 4e2ab295..de72dca6 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 b46cc865..0775869c 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; @@ -38,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(); @@ -184,6 +186,12 @@ private void DrainAll() { try { + // Tracks whether a reentrant drain ran during the prior delivery, surfaced to the + // callback so consumers can branch on the distinction. Reset to false at the start + // of each iteration (after consumption) so each callback invocation sees only the + // reentrancy that occurred during the immediately preceding delivery cycle. + var wasReentrant = false; + while (true) { if (!DrainPending()) @@ -202,13 +210,14 @@ private void DrainAll() return; } - // Reset before the callback so the flag exclusively reflects reentrant - // drains triggered by _onDrainComplete itself. + // Capture and reset before the callback so the flag exclusively reflects reentrant + // drains triggered by _onDrainComplete itself on the next iteration. + wasReentrant = _drainReentered; _drainReentered = false; if (_onDrainComplete is not null) { - _onDrainComplete(); + _onDrainComplete(wasReentrant); } // Loop back if items are pending OR if a reentrant drain ran during From 48e72642905d9f3ed625b77e0ac44ff1711dedc6 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Thu, 11 Jun 2026 09:03:36 -0700 Subject: [PATCH 20/30] Add isolation test fixtures for all cache orchestrators Adds the level-1 (in-isolation) test pattern complementing the existing level-2 (end-to-end) fixtures. Each orchestrator can now be tested two ways: via OrchestrateMany through the full SharedDeliveryQueue runtime (already covered by existing fixtures like AutoRefreshOnObservableFixture, TransformManyAsyncFixture), or directly using FakeOrchestratorContext to drive method calls and assert on the orchestrator's contract with its context (Track/Untrack calls) and emit policy. New isolation fixtures: - MergeManyOrchestratorFixture (4 tests): Add-tracks, Remove-untracks, OnInner-passthrough, OnDrainComplete-is-noop - MergeManyItemsOrchestratorFixture (2 tests): ItemWithValue pairing, Untrack on Remove - TransformManyAsyncOrchestratorFixture (4 tests): tracking, untrack, drain-emits-tracker, transformer-throws-routes-through-error-handler - MergedOrchestratorFixture (2 tests): Add-tracks-and-routes, Remove-untracks - MergedListOrchestratorFixture (2 tests): list parity - ChangesOrchestratorFixture (3 tests): onSourceChange per change, onInner routes, Untrack on Remove - LambdaCacheOrchestratorFixture (3 tests): forwarding contract for the 3-lambda overload - AutoRefreshOrchestratorFixture (5 tests): unbuffered drain flush, source-touched suppression, buffered defers to timer, buffered+isFinal flushes synchronously, Remove drops pending refresh Required visibility bumps from private to internal on 8 nested orchestrator classes (still inside internal enclosing types, so no expanded public surface). Supporting infrastructure: - FakeOrchestratorContext: in-memory ICacheOrchestratorContext that records Track/Untrack calls without subscribing to observables (Serialize returns the observable as-is). Tests must call OnInner manually. - CollectingObserver: minimal IObserver that records values + terminal state. Tests: 2432/2432 pass (+25). TransformManyAsync flake check 30/30 pass. --- .../AutoRefreshOrchestratorFixture.cs | 161 ++++++++++++++++++ .../Internal/ChangesOrchestratorFixture.cs | 85 +++++++++ .../LambdaCacheOrchestratorFixture.cs | 91 ++++++++++ .../MergeManyItemsOrchestratorFixture.cs | 58 +++++++ .../Internal/MergeManyOrchestratorFixture.cs | 90 ++++++++++ .../Internal/MergedListOrchestratorFixture.cs | 58 +++++++ .../Internal/MergedOrchestratorFixture.cs | 63 +++++++ .../TransformManyAsyncOrchestratorFixture.cs | 114 +++++++++++++ .../Utilities/CollectingObserver.cs | 30 ++++ .../Utilities/FakeOrchestratorContext.cs | 54 ++++++ src/DynamicData/Cache/Internal/AutoRefresh.cs | 2 +- .../IntObservableCacheEx.OrchestrateMany.cs | 2 +- ...bservableCacheEx.OrchestrateManyChanges.cs | 2 +- ...ObservableCacheEx.OrchestrateManyMerged.cs | 2 +- ...rvableCacheEx.OrchestrateManyMergedList.cs | 2 +- src/DynamicData/Cache/Internal/MergeMany.cs | 2 +- .../Cache/Internal/MergeManyItems.cs | 2 +- .../Cache/Internal/TransformManyAsync.cs | 2 +- 18 files changed, 812 insertions(+), 8 deletions(-) create mode 100644 src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs create mode 100644 src/DynamicData.Tests/Internal/ChangesOrchestratorFixture.cs create mode 100644 src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs create mode 100644 src/DynamicData.Tests/Internal/MergeManyItemsOrchestratorFixture.cs create mode 100644 src/DynamicData.Tests/Internal/MergeManyOrchestratorFixture.cs create mode 100644 src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs create mode 100644 src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs create mode 100644 src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs create mode 100644 src/DynamicData.Tests/Utilities/CollectingObserver.cs create mode 100644 src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs diff --git a/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs new file mode 100644 index 00000000..e91ac7f2 --- /dev/null +++ b/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs @@ -0,0 +1,161 @@ +// 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 Xunit; + +namespace DynamicData.Tests.Internal; + +/// +/// In-isolation tests for . Covers +/// per-key refresh accumulation, source-touched suppression (a reevaluator emission that fires +/// synchronously during item subscription must not produce a redundant Refresh paired with the Add), +/// and the buffered vs. unbuffered drain-flush policy including the isFinal synchronous flush. +/// +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) }); + // The source forwards the original changeset directly: + emitter.Values.Should().HaveCount(1); + + // Clear sourceTouched by draining once with no pending state. + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + // After the source-touched window closes, an inner refresh becomes a real pending refresh. + 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); + // Same source drain: Add fires (marks key as sourceTouched), then a synchronous inner refresh. + 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 Microsoft.Reactive.Testing.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 Microsoft.Reactive.Testing.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); + + // Source removes the item BEFORE the drain flushes; pending refresh must be dropped. + 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"); + } +} diff --git a/src/DynamicData.Tests/Internal/ChangesOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/ChangesOrchestratorFixture.cs new file mode 100644 index 00000000..b538ae39 --- /dev/null +++ b/src/DynamicData.Tests/Internal/ChangesOrchestratorFixture.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; +using System.Reactive.Linq; + +using DynamicData.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +/// +/// In-isolation tests for , +/// the orchestrator behind OrchestrateManyChanges. Verifies that onSourceChange and onInner +/// callbacks fire for the right reasons and that drain-end captures and emits accumulated state. +/// +public sealed class ChangesOrchestratorFixture +{ + 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.ChangesOrchestrator( + 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.ChangesOrchestrator( + 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.ChangesOrchestrator( + 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 }); + } +} diff --git a/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs new file mode 100644 index 00000000..61afda12 --- /dev/null +++ b/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.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; +using System.Collections.Generic; + +using DynamicData.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +/// +/// In-isolation smoke tests for . +/// Verifies that calls forward to the captured lambdas and that the Track callback is wired to the +/// supplied context. Most production behavior is covered through the lambda-overload tests in +/// OrchestrateManyFixture; these tests confirm the forwarding contract in isolation. +/// +public sealed class LambdaCacheOrchestratorFixture +{ + private sealed record Item(int Id); + + [Fact] + public void OnSourceChangeSet_ForwardsToLambdaWithTrackAndUntrack() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver(); + var receivedChanges = new List>(); + Action>? receivedTrack = null; + Action? receivedUntrack = null; + + var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( + context, emitter, + onSourceChangeSet: (changes, track, untrack) => + { + receivedChanges.Add(changes); + receivedTrack = track; + receivedUntrack = untrack; + }, + onInner: (value, key) => { }, + onDrainComplete: obs => { }); + + var changeset = new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }; + orchestrator.OnSourceChangeSet(changeset); + + receivedChanges.Should().HaveCount(1); + receivedChanges[0].Should().BeSameAs(changeset); + receivedTrack.Should().NotBeNull(); + receivedUntrack.Should().NotBeNull(); + } + + [Fact] + public void OnInner_ForwardsToLambda() + { + 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 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/MergeManyItemsOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergeManyItemsOrchestratorFixture.cs new file mode 100644 index 00000000..ea80db9b --- /dev/null +++ b/src/DynamicData.Tests/Internal/MergeManyItemsOrchestratorFixture.cs @@ -0,0 +1,58 @@ +// 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.Subjects; + +using DynamicData.Cache.Internal; +using DynamicData.Kernel; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +/// +/// In-isolation tests for . +/// Differs from MergeMany in that emissions are wrapped with the source item, producing +/// . +/// +public sealed class MergeManyItemsOrchestratorFixture +{ + private sealed record Item(int Id, string Name); + + [Fact] + public void OnInner_EmitsItemWithValuePairing() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver>(); + var item = new Item(1, "alpha"); + + var orchestrator = new MergeManyItems.Orchestrator( + context, emitter, (i, k) => new Subject()); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); + + orchestrator.OnInner((item, "hello"), 1); + + emitter.Values.Should().HaveCount(1); + emitter.Values[0].Item.Should().BeSameAs(item); + emitter.Values[0].Value.Should().Be("hello"); + } + + [Fact] + public void OnItemRemoved_UntracksKey() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver>(); + var orchestrator = new MergeManyItems.Orchestrator( + context, emitter, (i, k) => new Subject()); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1, "x")) }); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 1, new Item(1, "x")) }); + + context.UntrackCalls.Should().Equal(new[] { 1 }); + } +} diff --git a/src/DynamicData.Tests/Internal/MergeManyOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergeManyOrchestratorFixture.cs new file mode 100644 index 00000000..1eb89d00 --- /dev/null +++ b/src/DynamicData.Tests/Internal/MergeManyOrchestratorFixture.cs @@ -0,0 +1,90 @@ +// 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.Reactive.Subjects; + +using DynamicData.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +/// +/// In-isolation tests for , driven +/// directly through without involving the +/// SharedDeliveryQueue runtime. Verifies the orchestrator's contract with its context (Track on Add, +/// Untrack on Remove) and its emit policy (one OnNext per inner emission, no coalescing). +/// +public sealed class MergeManyOrchestratorFixture +{ + private sealed record Item(int Id, string Name); + + [Fact] + public void OnItemAdded_TracksInnerObservableForKey() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver(); + var inner = new Subject(); + + var orchestrator = new MergeMany.Orchestrator( + context, emitter, (item, key) => inner); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 42, new Item(42, "alpha")) }); + + context.TrackCalls.Should().HaveCount(1, "Add must produce exactly one Track call"); + context.TrackCalls[0].Key.Should().Be(42); + context.Tracked.Should().ContainKey(42); + } + + [Fact] + public void OnItemRemoved_UntracksInnerObservable() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver(); + var orchestrator = new MergeMany.Orchestrator( + context, emitter, (item, key) => new Subject()); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 7, new Item(7, "x")) }); + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 7, new Item(7, "x")) }); + + context.UntrackCalls.Should().Equal(new[] { 7 }, "Remove must produce exactly one Untrack call"); + context.Tracked.Should().NotContainKey(7); + } + + [Fact] + public void OnInner_ForwardsValueToEmitterImmediately() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver(); + var orchestrator = new MergeMany.Orchestrator( + context, emitter, (item, key) => new Subject()); + + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1, "x")) }); + + orchestrator.OnInner("first", 1); + orchestrator.OnInner("second", 1); + + emitter.Values.Should().Equal(new[] { "first", "second" }, "MergeMany emits each inner value passthrough"); + } + + [Fact] + public void OnDrainComplete_DoesNothingByItself() + { + var context = new FakeOrchestratorContext(); + var emitter = new CollectingObserver(); + var orchestrator = new MergeMany.Orchestrator( + context, emitter, (item, key) => new Subject()); + + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + orchestrator.OnDrainComplete(isFinal: true, wasReentrant: false); + + emitter.Values.Should().BeEmpty("MergeMany does not accumulate state and therefore emits nothing at drain end"); + emitter.IsCompleted.Should().BeFalse("OnDrainComplete is not responsible for downstream completion"); + } +} diff --git a/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs new file mode 100644 index 00000000..49269ec9 --- /dev/null +++ b/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs @@ -0,0 +1,58 @@ +// 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.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +/// +/// In-isolation tests for , +/// the cache-source-to-list-merged orchestrator. Verifies per-source-key list tracking and removal +/// semantics through the ChangeSetMergeTracker. +/// +public sealed class MergedListOrchestratorFixture +{ + private sealed record Item(int Id); + + [Fact] + public void OnItemAdded_TracksAndForwardsListChanges() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = new IntObservableCacheEx.MergedListOrchestrator( + context, emitter, + changeSetSelector: (item, key) => Observable.Empty>(), + equalityComparer: null); + + 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 = new IntObservableCacheEx.MergedListOrchestrator( + context, emitter, + changeSetSelector: (item, key) => Observable.Empty>(), + equalityComparer: 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().Contain(1, "Remove must propagate as Untrack on the orchestrator's context"); + } +} diff --git a/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs new file mode 100644 index 00000000..246a65ce --- /dev/null +++ b/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs @@ -0,0 +1,63 @@ +// 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.Cache.Internal; +using DynamicData.Tests.Utilities; + +using FluentAssertions; + +using Xunit; + +namespace DynamicData.Tests.Internal; + +/// +/// In-isolation tests for , +/// the orchestrator behind OrchestrateManyMerged. Verifies per-source-key tracking, update +/// semantics (new tracked + prior items removed from tracker), and the reevalOnRefresh flag. +/// +public sealed class MergedOrchestratorFixture +{ + private sealed record Item(int Id, string Tag); + + [Fact] + public void OnItemAdded_TracksAndRoutesInnerChangeSet() + { + var context = new FakeOrchestratorContext>(); + var emitter = new CollectingObserver>(); + var orchestrator = new IntObservableCacheEx.MergedOrchestrator( + context, emitter, + changeSetSelector: (item, key) => Observable.Empty>(), + equalityComparer: null, + comparer: null, + reevalOnRefresh: false); + + 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 = new IntObservableCacheEx.MergedOrchestrator( + context, emitter, + changeSetSelector: (item, key) => Observable.Empty>(), + equalityComparer: null, + comparer: null, + reevalOnRefresh: false); + + 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"); + } +} diff --git a/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs new file mode 100644 index 00000000..c31c8599 --- /dev/null +++ b/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs @@ -0,0 +1,114 @@ +// 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; + +/// +/// In-isolation tests for . +/// Verifies that async transformer results are tracked per key, that removals untrack and clean the +/// merge tracker, that drain end flushes accumulated changes, and that a transformer exception is +/// routed through the user-provided error handler. +/// +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)) }); + + // The deferred transform subscribes when we drive the tracked observable; subscribe now and + // settle the asynchronous defer to surface the error to the handler. + var trackedObs = context.Tracked[1]; + using var sub = trackedObs.Subscribe(); + await Task.Delay(20); + + capturedError.Should().NotBeNull(); + capturedError!.Exception!.Message.Should().Be("transformer-broke"); + } +} diff --git a/src/DynamicData.Tests/Utilities/CollectingObserver.cs b/src/DynamicData.Tests/Utilities/CollectingObserver.cs new file mode 100644 index 00000000..92d8f85f --- /dev/null +++ b/src/DynamicData.Tests/Utilities/CollectingObserver.cs @@ -0,0 +1,30 @@ +// 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; + +/// +/// Minimal that records every OnNext value and the terminal state. +/// Intended for in-isolation orchestrator tests where the orchestrator's emitter side-effects +/// need to be captured without involving an Rx pipeline. +/// +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 00000000..da57225a --- /dev/null +++ b/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs @@ -0,0 +1,54 @@ +// 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; + +/// +/// In-memory implementation of for testing +/// orchestrators in isolation, without spinning up the full SharedDeliveryQueue + Orchestration +/// runtime. Records every and call so tests can assert on +/// the orchestrator's subscription lifecycle behavior, and exposes the currently tracked 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 = []; + + /// Snapshot of every Track call made on this context, in order of receipt. + public List<(TKey Key, IObservable Observable)> TrackCalls { get; } = []; + + /// Snapshot of every Untrack call made on this context, in order of receipt. + public List UntrackCalls { get; } = []; + + /// Currently registered observables, keyed by their source key. Reflects Track/Untrack history. + 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); + } + + /// + /// Returns unchanged. The fake does not provide queue-style + /// serialization; tests that need ordering guarantees should drive the orchestrator + /// synchronously from a single thread. + /// + public IObservable Serialize(IObservable observable) => observable; +} diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index 4ebbc093..861667a2 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -35,7 +35,7 @@ public IObservable> Run() /// drain cycle are suppressed, so a reevaluator that fires synchronously during item /// subscription does not produce a redundant Refresh paired with the Add. /// - private sealed class Orchestrator( + internal sealed class Orchestrator( ICacheOrchestratorContext> context, IObserver> emitter, Func> reEvaluator, diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs index e5933bb5..1c12f52d 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs @@ -65,7 +65,7 @@ public static IObservable OrchestrateLambdas>( (context, emitter) => new LambdaCacheOrchestrator(context, emitter, onSourceChangeSet, onInner, onDrainComplete)); - private sealed class LambdaCacheOrchestrator( + internal sealed class LambdaCacheOrchestrator( ICacheOrchestratorContext context, IObserver emitter, Action, Action>, Action> onSourceChangeSet, diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs index 24294a1f..a68bb496 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs @@ -45,7 +45,7 @@ public static IObservable> OrchestrateManyChanges, ChangesOrchestrator>( (context, emitter) => new ChangesOrchestrator(context, emitter, innerFactory, onSourceChange, onInner)); - private sealed class ChangesOrchestrator( + internal sealed class ChangesOrchestrator( ICacheOrchestratorContext context, IObserver> emitter, Func> innerFactory, diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs index 5a5b08c7..3f48970b 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs @@ -38,7 +38,7 @@ public static IObservable> OrchestrateManyMerged, IChangeSet, MergedOrchestrator>( (context, emitter) => new MergedOrchestrator(context, emitter, changeSetSelector, equalityComparer, comparer, reevalOnRefresh)); - private sealed class MergedOrchestrator : OrchestratorCacheChangeBase, IChangeSet> + internal sealed class MergedOrchestrator : OrchestratorCacheChangeBase, IChangeSet> where TSource : notnull where TKey : notnull where TDest : notnull diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs index 02b6f68e..4767b3d5 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs @@ -32,7 +32,7 @@ public static IObservable> OrchestrateManyMergedList, IChangeSet, MergedListOrchestrator>( (context, emitter) => new MergedListOrchestrator(context, emitter, changeSetSelector, equalityComparer)); - private sealed class MergedListOrchestrator : OrchestratorCacheChangeBase, IChangeSet> + internal sealed class MergedListOrchestrator : OrchestratorCacheChangeBase, IChangeSet> where TSource : notnull where TKey : notnull where TDest : notnull diff --git a/src/DynamicData/Cache/Internal/MergeMany.cs b/src/DynamicData/Cache/Internal/MergeMany.cs index a329a728..7842ff34 100644 --- a/src/DynamicData/Cache/Internal/MergeMany.cs +++ b/src/DynamicData/Cache/Internal/MergeMany.cs @@ -31,7 +31,7 @@ public IObservable Run() => _source.OrchestrateMany( (context, emitter) => new Orchestrator(context, emitter, _observableSelector)); - private sealed class Orchestrator( + internal sealed class Orchestrator( ICacheOrchestratorContext context, IObserver emitter, Func> selector) diff --git a/src/DynamicData/Cache/Internal/MergeManyItems.cs b/src/DynamicData/Cache/Internal/MergeManyItems.cs index 7ee94a5a..74bdefe0 100644 --- a/src/DynamicData/Cache/Internal/MergeManyItems.cs +++ b/src/DynamicData/Cache/Internal/MergeManyItems.cs @@ -31,7 +31,7 @@ public IObservable> Run() => _source.OrchestrateMany, Orchestrator>( (context, emitter) => new Orchestrator(context, emitter, _observableSelector)); - private sealed class Orchestrator( + internal sealed class Orchestrator( ICacheOrchestratorContext context, IObserver> emitter, Func> selector) diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index 9fcb15a0..3321f937 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -22,7 +22,7 @@ public IObservable> Run() => source.OrchestrateMany, IChangeSet, Orchestrator>( (context, emitter) => new Orchestrator(context, emitter, transformer, equalityComparer, comparer, errorHandler)); - private sealed class Orchestrator : OrchestratorCacheChangeBase, IChangeSet> + internal sealed class Orchestrator : OrchestratorCacheChangeBase, IChangeSet> { private readonly Cache, TKey> _cache = new(); private readonly ChangeSetMergeTracker _tracker; From cb5f41dcfd5775c55bf87b4ceb0bde20964ec55f Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 14 Jun 2026 14:18:41 -0700 Subject: [PATCH 21/30] Address review feedback on orchestrator primitive Four changes: 1. SizeLimitFixture.OnCompleteIsInvokedWhenSourceIsDisposed: re-skip with the original message. The test is unrelated to this branch's changes (LimitSizeTo was not migrated to the orchestrator) and is racy under full-suite ThreadPool load. The previous commit un-skipped it speculatively; that was a mistake. 2. Orchestration.OrchestratorContext: extend the try/catch to cover all construction steps (CreateQueue, factory, source subscribe), not just the factory call. Without this, an exception from CreateQueue or the source subscribe leaks the queue/emitter/orchestrator/source-subscription because the ctor never completes and Dispose never runs. The catch block tears down everything that was successfully allocated. 3. AutoRefresh.Orchestrator.OnItemRefreshed: also DropPending(key) when the source forwards a Refresh. Without this, a pending refresh from a prior drain (queued by the reevaluator) and the source's Refresh were both emitted to the consumer for the same item, in quick succession. Symmetrical with OnItemUpdated which already drops pending. Regression test added in AutoRefreshOrchestratorFixture. 4. Rename OrchestrateLambdas to an OrchestrateMany overload and reshape the source-change callback. The (changes, track, untrack) shape was messy and could not expose the full ICacheOrchestratorContext (no Serialize, no Untrack-by-token). The new shape is (changes, context) passing the runtime context directly. GroupOnObservable and TransformOnObservable updated. Test fixtures updated. --- .../Cache/SizeLimitFixture.cs | 2 +- .../AutoRefreshOrchestratorFixture.cs | 37 +++++++++++++++++++ .../LambdaCacheOrchestratorFixture.cs | 17 ++++----- .../Internal/OrchestrateManyFixture.cs | 14 +++---- src/DynamicData/Cache/Internal/AutoRefresh.cs | 10 ++++- .../Cache/Internal/GroupOnObservable.cs | 8 ++-- .../IntObservableCacheEx.OrchestrateMany.cs | 21 ++++++----- .../Cache/Internal/Orchestration.cs | 35 ++++++++++-------- .../Cache/Internal/TransformOnObservable.cs | 10 ++--- 9 files changed, 100 insertions(+), 54 deletions(-) diff --git a/src/DynamicData.Tests/Cache/SizeLimitFixture.cs b/src/DynamicData.Tests/Cache/SizeLimitFixture.cs index f20b590b..378b7fa6 100644 --- a/src/DynamicData.Tests/Cache/SizeLimitFixture.cs +++ b/src/DynamicData.Tests/Cache/SizeLimitFixture.cs @@ -125,7 +125,7 @@ public void InvokeLimitSizeToWhenOverLimit() subscriber.Dispose(); } - [Fact] + [Fact(Skip = "Need to re-examine and fix failure")] public void OnCompleteIsInvokedWhenSourceIsDisposed() { var completed = false; diff --git a/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs index e91ac7f2..0ffdd935 100644 --- a/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs @@ -158,4 +158,41 @@ public void OnItemRemoved_DropsPendingRefreshForKey() 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 Microsoft.Reactive.Testing.TestScheduler(); + var orchestrator = new AutoRefresh.Orchestrator( + context, emitter, + reEvaluator: (item, key) => new Subject(), + buffer: TimeSpan.FromSeconds(10), + scheduler: scheduler); + + var item = new Item(1); + // Establish the item and let sourceTouched clear so a subsequent inner emission counts as pending. + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + // Queue a pending refresh from the inner, then drain with timer NOT advanced so it stays pending. + orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); + orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); + + var preSourceRefreshCount = emitter.Values.Count; + + // Source emits Refresh(K) in a new drain. The pending refresh from the inner is now redundant + // because the source's Refresh is forwarded immediately. Drop the pending so the consumer + // does not see two Refresh notifications for the same item. + orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Refresh, 1, item) }); + // isFinal=true forces a synchronous flush of any remaining pending entries. + orchestrator.OnDrainComplete(isFinal: true, wasReentrant: false); + + // Expect exactly ONE additional emission (the source-forwarded Refresh). Without the + // DropPending in OnItemRefreshed, the buffered flush would emit a redundant second one. + 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/LambdaCacheOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs index 61afda12..f27bf5c4 100644 --- a/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs @@ -25,21 +25,19 @@ public sealed class LambdaCacheOrchestratorFixture private sealed record Item(int Id); [Fact] - public void OnSourceChangeSet_ForwardsToLambdaWithTrackAndUntrack() + public void OnSourceChangeSet_ForwardsToLambdaWithContext() { var context = new FakeOrchestratorContext(); var emitter = new CollectingObserver(); var receivedChanges = new List>(); - Action>? receivedTrack = null; - Action? receivedUntrack = null; + ICacheOrchestratorContext? receivedContext = null; var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( context, emitter, - onSourceChangeSet: (changes, track, untrack) => + onSourceChangeSet: (changes, ctx) => { receivedChanges.Add(changes); - receivedTrack = track; - receivedUntrack = untrack; + receivedContext = ctx; }, onInner: (value, key) => { }, onDrainComplete: obs => { }); @@ -49,8 +47,7 @@ public void OnSourceChangeSet_ForwardsToLambdaWithTrackAndUntrack() receivedChanges.Should().HaveCount(1); receivedChanges[0].Should().BeSameAs(changeset); - receivedTrack.Should().NotBeNull(); - receivedUntrack.Should().NotBeNull(); + receivedContext.Should().BeSameAs(context, "the lambda overload forwards the captured context as-is"); } [Fact] @@ -62,7 +59,7 @@ public void OnInner_ForwardsToLambda() var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( context, emitter, - onSourceChangeSet: (_, _, _) => { }, + onSourceChangeSet: (_, _) => { }, onInner: (v, k) => received.Add((v, k)), onDrainComplete: _ => { }); @@ -80,7 +77,7 @@ public void OnDrainComplete_ForwardsEmitterToLambda() var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( context, emitter, - onSourceChangeSet: (_, _, _) => { }, + onSourceChangeSet: (_, _) => { }, onInner: (_, _) => { }, onDrainComplete: obs => receivedObserver = obs); diff --git a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs index abbd1abd..600c81b6 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs @@ -456,17 +456,15 @@ public void LambdaOverload_MultipleSubscriptions_DoNotShareOrchestrator() var emitCalls = 0; var contexts = new List(); - // Build a chain that captures whichever context (via the track callback's identity) each - // orchestrator received. Two subscribers should each see their own context. - var observable = source.Connect().OrchestrateLambdas( - onSourceChangeSet: (changes, track, untrack) => + // Build a chain that captures whichever context each orchestrator received. Two subscribers + // should each see their own context instance. + var observable = source.Connect().OrchestrateMany( + onSourceChangeSet: (changes, context) => { - // Capture an identity-stable token for the track delegate, proving each subscription - // has its own context. Hash code of the bound delegate target reflects the underlying - // OrchestratorContext instance. + // Hash code of the context instance proves each subscription has its own. lock (contexts) { - contexts.Add(System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(track.Target!)); + contexts.Add(System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(context)); } }, onInner: (_, _) => { }, diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index 861667a2..aee43f7c 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -107,7 +107,15 @@ protected override void OnItemRemoved(TObject item, TKey key) Context.Untrack(key); } - protected override void OnItemRefreshed(TObject item, TKey key) => _sourceTouched.Add(key); + protected override void OnItemRefreshed(TObject item, TKey key) + { + _sourceTouched.Add(key); + + // The source's Refresh is being forwarded synchronously in OnSourceChangeSet, so any + // pending refresh queued for this key from a prior drain is now redundant. Drop it so the + // consumer doesn't see two Refresh notifications for the same item in quick succession. + DropPending(key); + } private void DropPending(TKey key) { diff --git a/src/DynamicData/Cache/Internal/GroupOnObservable.cs b/src/DynamicData/Cache/Internal/GroupOnObservable.cs index 7af358e2..1d27aa71 100644 --- a/src/DynamicData/Cache/Internal/GroupOnObservable.cs +++ b/src/DynamicData/Cache/Internal/GroupOnObservable.cs @@ -15,8 +15,8 @@ internal sealed class GroupOnObservable(IObservable> Run() => Observable.Using( resourceFactory: () => new DynamicGrouper(), - observableFactory: grouper => source.OrchestrateLambdas>( - onSourceChangeSet: (changes, track, untrack) => + observableFactory: grouper => source.OrchestrateMany>( + onSourceChangeSet: (changes, context) => { foreach (var change in changes.ToConcreteType()) { @@ -27,11 +27,11 @@ public IObservable> Run() => case ChangeReason.Add or ChangeReason.Update: var item = change.Current; var key = change.Key; - track(key, selectGroup(item, key).DistinctUntilChanged().Select(groupKey => (groupKey, item))); + context.Track(key, selectGroup(item, key).DistinctUntilChanged().Select(groupKey => (groupKey, item))); break; case ChangeReason.Remove: - untrack(change.Key); + context.Untrack(change.Key); break; } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs index 1c12f52d..d54d59ab 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs @@ -39,24 +39,25 @@ public static IObservable OrchestrateMany(source, factory).Run(); /// - /// Convenience method that wraps three lambdas into an . - /// Does not expose ; operators that - /// need it must implement directly. - /// The lambdas may register inner subscriptions via the track callback (paired with - /// untrack for removal). + /// Convenience overload of + /// that wraps three lambdas into an . + /// The source-change lambda receives the full + /// so it can call , + /// , or + /// as needed. /// /// 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 by . /// The keyed source changeset stream. - /// Invoked for each source changeset, paired with track and untrack callbacks. + /// Invoked for each source changeset, paired with the runtime context. /// Invoked for each value emitted by a tracked inner observable, paired with its key. /// Invoked once per drain cycle to flush the aggregated state to the emitter. /// An observable that orchestrates source and inner activity into a single result stream. - public static IObservable OrchestrateLambdas( + public static IObservable OrchestrateMany( this IObservable> source, - Action, Action>, Action> onSourceChangeSet, + Action, ICacheOrchestratorContext> onSourceChangeSet, Action onInner, Action> onDrainComplete) where TSource : notnull @@ -68,7 +69,7 @@ public static IObservable OrchestrateLambdas( ICacheOrchestratorContext context, IObserver emitter, - Action, Action>, Action> onSourceChangeSet, + Action, ICacheOrchestratorContext> onSourceChangeSet, Action onInner, Action> onDrainComplete) : ICacheOrchestrator @@ -76,7 +77,7 @@ internal sealed class LambdaCacheOrchestrator( where TKey : notnull where TInner : notnull { - public void OnSourceChangeSet(IChangeSet changes) => onSourceChangeSet(changes, context.Track, context.Untrack); + public void OnSourceChangeSet(IChangeSet changes) => onSourceChangeSet(changes, context); public void OnInner(TInner value, TKey key) => onInner(value, key); diff --git a/src/DynamicData/Cache/Internal/Orchestration.cs b/src/DynamicData/Cache/Internal/Orchestration.cs index 1b62ccbf..b8da6ec8 100644 --- a/src/DynamicData/Cache/Internal/Orchestration.cs +++ b/src/DynamicData/Cache/Internal/Orchestration.cs @@ -60,31 +60,36 @@ public OrchestratorContext( { _queue = new SharedDeliveryQueue(onDrainComplete: OnDrainComplete); - // Create the emitter sub-queue first (lowest index, drains last LIFO) so source-triggered - // sync inner emissions (higher index) deliver first and any orchestrator emit lands on the - // emitter after that work has settled. - _emitter = _queue.CreateQueue(observer); - - // Wrap the factory call so a throw is reported via the standard error channel and any - // queue/emitter we already allocated are released. + // Wrap construction from the emitter sub-queue allocation through the source subscription + // so any throw on the way up releases everything we've allocated so far. Without this, an + // exception from CreateQueue, the factory, or the source subscribe leaks the queue/emitter/ + // orchestrator/source-subscription because the ctor never completes and Dispose never runs. try { + // Create the emitter sub-queue first (lowest index, drains last LIFO) so source-triggered + // sync inner emissions (higher index) deliver first and any orchestrator emit lands on the + // emitter after that work has settled. + _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(); + _emitter?.Dispose(); + (_orchestrator as IDisposable)?.Dispose(); throw; } - - _sourceSubscription.Disposable = source - .SynchronizeSafe(_queue) - .SubscribeSafe( - onNext: OnSourceChangeSet, - onError: _emitter.OnError, - onCompleted: DecrementSubscriptionCount); } public void Dispose() diff --git a/src/DynamicData/Cache/Internal/TransformOnObservable.cs b/src/DynamicData/Cache/Internal/TransformOnObservable.cs index 6ef32d3b..dcfcc191 100644 --- a/src/DynamicData/Cache/Internal/TransformOnObservable.cs +++ b/src/DynamicData/Cache/Internal/TransformOnObservable.cs @@ -16,26 +16,26 @@ public IObservable> Run() => Observable.Defer(() { var cache = new ChangeAwareCache(); - return source.OrchestrateLambdas>( - onSourceChangeSet: (changes, track, untrack) => + return source.OrchestrateMany>( + onSourceChangeSet: (changes, context) => { foreach (var change in changes.ToConcreteType()) { switch (change.Reason) { case ChangeReason.Add or ChangeReason.Update: - track(change.Key, transform(change.Current, change.Key).DistinctUntilChanged()); + context.Track(change.Key, transform(change.Current, change.Key).DistinctUntilChanged()); break; case ChangeReason.Remove: cache.Remove(change.Key); - untrack(change.Key); + context.Untrack(change.Key); break; case ChangeReason.Refresh: if (transformOnRefresh) { - track(change.Key, transform(change.Current, change.Key).DistinctUntilChanged()); + context.Track(change.Key, transform(change.Current, change.Key).DistinctUntilChanged()); } else { From ef4ceee9e4869b11872617c2a31a2145d12b8090 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 14 Jun 2026 15:49:37 -0700 Subject: [PATCH 22/30] Refactor: unify and rename orchestration primitives Major internal refactor to unify orchestration logic for cache operators. Renamed OrchestrateMany to Orchestrate (and related types), updated all usages, and consolidated cache/list merging logic into new OrchestrateChangeSets/OrchestrateManyChangeSets methods. Lambda orchestrators now receive the emitter in onInner. Removed obsolete orchestrator classes and merged related logic. Updated tests and internal docs to match new structure. No public API changes. --- ...ure.cs => ChangeSetOrchestratorFixture.cs} | 12 +- .../LambdaCacheOrchestratorFixture.cs | 16 +- .../MergeManyItemsOrchestratorFixture.cs | 58 ------ .../Internal/MergeManyOrchestratorFixture.cs | 90 -------- .../Internal/MergedOrchestratorFixture.cs | 2 +- ...teManyFixture.cs => OrchestrateFixture.cs} | 20 +- .../Utilities/FakeOrchestratorContext.cs | 2 +- src/DynamicData/Cache/Internal/AutoRefresh.cs | 4 +- ...Orchestration.cs => CacheOrchestration.cs} | 13 +- ...ChangeBase.cs => CacheOrchestratorBase.cs} | 2 +- .../Cache/Internal/FilterOnObservable.cs | 2 +- .../Cache/Internal/GroupOnObservable.cs | 4 +- .../Cache/Internal/ICacheOrchestrator.cs | 4 +- ...cs => IntObservableCacheEx.Orchestrate.cs} | 58 ++++-- ...bservableCacheEx.OrchestrateChangeSets.cs} | 12 +- ...rvableCacheEx.OrchestrateManyChangeSets.cs | 192 ++++++++++++++++++ ...ObservableCacheEx.OrchestrateManyMerged.cs | 110 ---------- ...rvableCacheEx.OrchestrateManyMergedList.cs | 91 --------- src/DynamicData/Cache/Internal/MergeMany.cs | 33 ++- .../Internal/MergeManyCacheChangeSets.cs | 24 --- .../MergeManyCacheChangeSetsSourceCompare.cs | 76 ------- .../Cache/Internal/MergeManyItems.cs | 34 ++-- .../Cache/Internal/MergeManyListChangeSets.cs | 2 +- .../Cache/Internal/TransformManyAsync.cs | 4 +- .../Cache/Internal/TransformOnObservable.cs | 4 +- .../ObservableCacheEx.MergeManyChangeSets.cs | 70 ++++++- 26 files changed, 388 insertions(+), 551 deletions(-) rename src/DynamicData.Tests/Internal/{ChangesOrchestratorFixture.cs => ChangeSetOrchestratorFixture.cs} (85%) delete mode 100644 src/DynamicData.Tests/Internal/MergeManyItemsOrchestratorFixture.cs delete mode 100644 src/DynamicData.Tests/Internal/MergeManyOrchestratorFixture.cs rename src/DynamicData.Tests/Internal/{OrchestrateManyFixture.cs => OrchestrateFixture.cs} (96%) rename src/DynamicData/Cache/Internal/{Orchestration.cs => CacheOrchestration.cs} (93%) rename src/DynamicData/Cache/Internal/{OrchestratorCacheChangeBase.cs => CacheOrchestratorBase.cs} (97%) rename src/DynamicData/Cache/Internal/{IntObservableCacheEx.OrchestrateMany.cs => IntObservableCacheEx.Orchestrate.cs} (58%) rename src/DynamicData/Cache/Internal/{IntObservableCacheEx.OrchestrateManyChanges.cs => IntObservableCacheEx.OrchestrateChangeSets.cs} (86%) create mode 100644 src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs delete mode 100644 src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs delete mode 100644 src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs delete mode 100644 src/DynamicData/Cache/Internal/MergeManyCacheChangeSets.cs delete mode 100644 src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs diff --git a/src/DynamicData.Tests/Internal/ChangesOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs similarity index 85% rename from src/DynamicData.Tests/Internal/ChangesOrchestratorFixture.cs rename to src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs index b538ae39..fac6b0e9 100644 --- a/src/DynamicData.Tests/Internal/ChangesOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs @@ -15,11 +15,11 @@ namespace DynamicData.Tests.Internal; /// -/// In-isolation tests for , -/// the orchestrator behind OrchestrateManyChanges. Verifies that onSourceChange and onInner +/// In-isolation tests for , +/// the orchestrator behind OrchestrateChangeSets. Verifies that onSourceChange and onInner /// callbacks fire for the right reasons and that drain-end captures and emits accumulated state. /// -public sealed class ChangesOrchestratorFixture +public sealed class ChangeSetOrchestratorFixture { private sealed record Source(int Id); @@ -30,7 +30,7 @@ public void OnSourceChangeSet_InvokesOnSourceChangeForEachChange() var emitter = new CollectingObserver>(); var reasons = new System.Collections.Generic.List(); - var orchestrator = new IntObservableCacheEx.ChangesOrchestrator( + var orchestrator = new IntObservableCacheEx.ChangeSetOrchestrator( context, emitter, innerFactory: (item, key) => Observable.Empty(), onSourceChange: (cache, change) => reasons.Add(change.Reason), @@ -52,7 +52,7 @@ public void OnInner_RoutesValueAndDrainCaptures() var emitter = new CollectingObserver>(); var source = new Source(1); - var orchestrator = new IntObservableCacheEx.ChangesOrchestrator( + var orchestrator = new IntObservableCacheEx.ChangeSetOrchestrator( context, emitter, innerFactory: (item, key) => Observable.Empty(), onSourceChange: (cache, change) => { }, @@ -71,7 +71,7 @@ public void OnItemRemoved_UntracksKey() { var context = new FakeOrchestratorContext(); var emitter = new CollectingObserver>(); - var orchestrator = new IntObservableCacheEx.ChangesOrchestrator( + var orchestrator = new IntObservableCacheEx.ChangeSetOrchestrator( context, emitter, innerFactory: (item, key) => Observable.Empty(), onSourceChange: (cache, change) => { }, diff --git a/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs index f27bf5c4..f9c0ea71 100644 --- a/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs @@ -18,7 +18,7 @@ namespace DynamicData.Tests.Internal; /// In-isolation smoke tests for . /// Verifies that calls forward to the captured lambdas and that the Track callback is wired to the /// supplied context. Most production behavior is covered through the lambda-overload tests in -/// OrchestrateManyFixture; these tests confirm the forwarding contract in isolation. +/// OrchestrateFixture; these tests confirm the forwarding contract in isolation. /// public sealed class LambdaCacheOrchestratorFixture { @@ -39,7 +39,7 @@ public void OnSourceChangeSet_ForwardsToLambdaWithContext() receivedChanges.Add(changes); receivedContext = ctx; }, - onInner: (value, key) => { }, + onInner: (value, key, _) => { }, onDrainComplete: obs => { }); var changeset = new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }; @@ -51,21 +51,27 @@ public void OnSourceChangeSet_ForwardsToLambdaWithContext() } [Fact] - public void OnInner_ForwardsToLambda() + public void OnInner_ForwardsToLambdaWithEmitter() { var context = new FakeOrchestratorContext(); var emitter = new CollectingObserver(); var received = new List<(string Value, int Key)>(); + IObserver? receivedEmitter = null; var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( context, emitter, onSourceChangeSet: (_, _) => { }, - onInner: (v, k) => received.Add((v, k)), + onInner: (v, k, em) => + { + received.Add((v, k)); + receivedEmitter = em; + }, onDrainComplete: _ => { }); orchestrator.OnInner("hello", 42); received.Should().Equal(new[] { ("hello", 42) }); + receivedEmitter.Should().BeSameAs(emitter, "the lambda overload forwards the emitter as-is to onInner"); } [Fact] @@ -78,7 +84,7 @@ public void OnDrainComplete_ForwardsEmitterToLambda() var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( context, emitter, onSourceChangeSet: (_, _) => { }, - onInner: (_, _) => { }, + onInner: (_, _, _) => { }, onDrainComplete: obs => receivedObserver = obs); orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); diff --git a/src/DynamicData.Tests/Internal/MergeManyItemsOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergeManyItemsOrchestratorFixture.cs deleted file mode 100644 index ea80db9b..00000000 --- a/src/DynamicData.Tests/Internal/MergeManyItemsOrchestratorFixture.cs +++ /dev/null @@ -1,58 +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.Subjects; - -using DynamicData.Cache.Internal; -using DynamicData.Kernel; -using DynamicData.Tests.Utilities; - -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.Internal; - -/// -/// In-isolation tests for . -/// Differs from MergeMany in that emissions are wrapped with the source item, producing -/// . -/// -public sealed class MergeManyItemsOrchestratorFixture -{ - private sealed record Item(int Id, string Name); - - [Fact] - public void OnInner_EmitsItemWithValuePairing() - { - var context = new FakeOrchestratorContext(); - var emitter = new CollectingObserver>(); - var item = new Item(1, "alpha"); - - var orchestrator = new MergeManyItems.Orchestrator( - context, emitter, (i, k) => new Subject()); - - orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); - - orchestrator.OnInner((item, "hello"), 1); - - emitter.Values.Should().HaveCount(1); - emitter.Values[0].Item.Should().BeSameAs(item); - emitter.Values[0].Value.Should().Be("hello"); - } - - [Fact] - public void OnItemRemoved_UntracksKey() - { - var context = new FakeOrchestratorContext(); - var emitter = new CollectingObserver>(); - var orchestrator = new MergeManyItems.Orchestrator( - context, emitter, (i, k) => new Subject()); - - orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1, "x")) }); - orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 1, new Item(1, "x")) }); - - context.UntrackCalls.Should().Equal(new[] { 1 }); - } -} diff --git a/src/DynamicData.Tests/Internal/MergeManyOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergeManyOrchestratorFixture.cs deleted file mode 100644 index 1eb89d00..00000000 --- a/src/DynamicData.Tests/Internal/MergeManyOrchestratorFixture.cs +++ /dev/null @@ -1,90 +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.Reactive.Subjects; - -using DynamicData.Cache.Internal; -using DynamicData.Tests.Utilities; - -using FluentAssertions; - -using Xunit; - -namespace DynamicData.Tests.Internal; - -/// -/// In-isolation tests for , driven -/// directly through without involving the -/// SharedDeliveryQueue runtime. Verifies the orchestrator's contract with its context (Track on Add, -/// Untrack on Remove) and its emit policy (one OnNext per inner emission, no coalescing). -/// -public sealed class MergeManyOrchestratorFixture -{ - private sealed record Item(int Id, string Name); - - [Fact] - public void OnItemAdded_TracksInnerObservableForKey() - { - var context = new FakeOrchestratorContext(); - var emitter = new CollectingObserver(); - var inner = new Subject(); - - var orchestrator = new MergeMany.Orchestrator( - context, emitter, (item, key) => inner); - - orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 42, new Item(42, "alpha")) }); - - context.TrackCalls.Should().HaveCount(1, "Add must produce exactly one Track call"); - context.TrackCalls[0].Key.Should().Be(42); - context.Tracked.Should().ContainKey(42); - } - - [Fact] - public void OnItemRemoved_UntracksInnerObservable() - { - var context = new FakeOrchestratorContext(); - var emitter = new CollectingObserver(); - var orchestrator = new MergeMany.Orchestrator( - context, emitter, (item, key) => new Subject()); - - orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 7, new Item(7, "x")) }); - orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 7, new Item(7, "x")) }); - - context.UntrackCalls.Should().Equal(new[] { 7 }, "Remove must produce exactly one Untrack call"); - context.Tracked.Should().NotContainKey(7); - } - - [Fact] - public void OnInner_ForwardsValueToEmitterImmediately() - { - var context = new FakeOrchestratorContext(); - var emitter = new CollectingObserver(); - var orchestrator = new MergeMany.Orchestrator( - context, emitter, (item, key) => new Subject()); - - orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1, "x")) }); - - orchestrator.OnInner("first", 1); - orchestrator.OnInner("second", 1); - - emitter.Values.Should().Equal(new[] { "first", "second" }, "MergeMany emits each inner value passthrough"); - } - - [Fact] - public void OnDrainComplete_DoesNothingByItself() - { - var context = new FakeOrchestratorContext(); - var emitter = new CollectingObserver(); - var orchestrator = new MergeMany.Orchestrator( - context, emitter, (item, key) => new Subject()); - - orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); - orchestrator.OnDrainComplete(isFinal: true, wasReentrant: false); - - emitter.Values.Should().BeEmpty("MergeMany does not accumulate state and therefore emits nothing at drain end"); - emitter.IsCompleted.Should().BeFalse("OnDrainComplete is not responsible for downstream completion"); - } -} diff --git a/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs index 246a65ce..62013c9d 100644 --- a/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs @@ -15,7 +15,7 @@ namespace DynamicData.Tests.Internal; /// /// In-isolation tests for , -/// the orchestrator behind OrchestrateManyMerged. Verifies per-source-key tracking, update +/// the orchestrator behind OrchestrateManyChangeSets. Verifies per-source-key tracking, update /// semantics (new tracked + prior items removed from tracker), and the reevalOnRefresh flag. /// public sealed class MergedOrchestratorFixture diff --git a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateFixture.cs similarity index 96% rename from src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs rename to src/DynamicData.Tests/Internal/OrchestrateFixture.cs index 600c81b6..7f91401d 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateManyFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateFixture.cs @@ -22,12 +22,12 @@ namespace DynamicData.Tests.Internal; /// -/// Tests for the OrchestrateMany primitive's behavioral contracts: source/inner serialization, +/// Tests for the Orchestrate primitive's behavioral contracts: source/inner serialization, /// per-drain coalesced emission, completion counting, error propagation, and cross-cache safety. /// Exercised via the overload because it /// maps 1:1 to the legacy CacheParentSubscription subclass shape these tests originally targeted. /// -public sealed class OrchestrateManyFixture +public sealed class OrchestrateFixture { private const int SeedMin = 1; private const int SeedMax = 10000; @@ -40,10 +40,10 @@ public sealed class OrchestrateManyFixture private sealed record TestItem(int Key, string Value); /// - /// Wires through OrchestrateMany with a fresh + /// Wires through Orchestrate with a fresh /// constructed per subscription. Returns the observable plus a thunk that yields the constructed /// orchestrator after subscribe. The factory pattern ensures per-subscription isolation and - /// matches the production OrchestrateMany contract. + /// matches the production Orchestrate contract. /// private static (IObservable> Observable, Func Orchestrator) Wire( IObservable> source, @@ -52,7 +52,7 @@ private static (IObservable> Observable, Func, TestOrchestrator>( + 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.")); } @@ -329,7 +329,7 @@ public void Serialization_ParentAndChildDoNotInterleave() } /// - /// Proves OrchestrateMany delivery runs without holding the lock. Two orchestrator instances + /// Proves Orchestrate delivery runs without holding the lock. Two orchestrator instances /// whose Emit callbacks write into each other's source cache, creating a cross-cache cycle. /// Deadlocks if downstream delivery is held under the queue lock; passes when the queue is /// drained before invoking Emit. @@ -372,7 +372,7 @@ public async Task DeadlockProof_CrossFeedingSubscriptions() var completed = Task.WhenAll(taskA, taskB); var finished = await Task.WhenAny(completed, Task.Delay(TimeSpan.FromSeconds(30))); finished.Should().BeSameAs(completed, - "cross-feeding OrchestrateMany subscriptions should not deadlock"); + "cross-feeding Orchestrate subscriptions should not deadlock"); } /// @@ -444,7 +444,7 @@ public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstrea } /// - /// The lambda overload of OrchestrateMany must build a fresh orchestrator per subscription; + /// The lambda overload of Orchestrate must build a fresh orchestrator per subscription; /// the orchestrator holds mutable per-subscription state and reuse across subscribers corrupts /// the first subscriber's context. /// @@ -458,7 +458,7 @@ public void LambdaOverload_MultipleSubscriptions_DoNotShareOrchestrator() // Build a chain that captures whichever context each orchestrator received. Two subscribers // should each see their own context instance. - var observable = source.Connect().OrchestrateMany( + var observable = source.Connect().Orchestrate( onSourceChangeSet: (changes, context) => { // Hash code of the context instance proves each subscription has its own. @@ -467,7 +467,7 @@ public void LambdaOverload_MultipleSubscriptions_DoNotShareOrchestrator() contexts.Add(System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(context)); } }, - onInner: (_, _) => { }, + onInner: (_, _, _) => { }, onDrainComplete: _ => Interlocked.Increment(ref emitCalls)); using var subA = observable.Subscribe(); diff --git a/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs b/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs index da57225a..80b474dc 100644 --- a/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs +++ b/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs @@ -11,7 +11,7 @@ namespace DynamicData.Tests.Utilities; /// /// In-memory implementation of for testing -/// orchestrators in isolation, without spinning up the full SharedDeliveryQueue + Orchestration +/// orchestrators in isolation, without spinning up the full SharedDeliveryQueue + CacheOrchestration /// runtime. Records every and call so tests can assert on /// the orchestrator's subscription lifecycle behavior, and exposes the currently tracked observables /// via . diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index aee43f7c..78a09cca 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -21,7 +21,7 @@ internal sealed class AutoRefresh( public IObservable> Run() { var sched = buffer is null ? null : scheduler ?? GlobalConfig.DefaultScheduler; - return source.OrchestrateMany, IChangeSet, Orchestrator>( + return source.Orchestrate, IChangeSet, Orchestrator>( (context, emitter) => new Orchestrator(context, emitter, reEvaluator, buffer, sched)); } @@ -41,7 +41,7 @@ internal sealed class Orchestrator( Func> reEvaluator, TimeSpan? buffer, IScheduler? scheduler) - : OrchestratorCacheChangeBase, IChangeSet>(context, emitter), IDisposable + : CacheOrchestratorBase, IChangeSet>(context, emitter), IDisposable { private readonly Dictionary> _pendingRefreshes = new(); private readonly HashSet _sourceTouched = []; diff --git a/src/DynamicData/Cache/Internal/Orchestration.cs b/src/DynamicData/Cache/Internal/CacheOrchestration.cs similarity index 93% rename from src/DynamicData/Cache/Internal/Orchestration.cs rename to src/DynamicData/Cache/Internal/CacheOrchestration.cs index b8da6ec8..dafca3db 100644 --- a/src/DynamicData/Cache/Internal/Orchestration.cs +++ b/src/DynamicData/Cache/Internal/CacheOrchestration.cs @@ -26,7 +26,7 @@ namespace DynamicData.Cache.Internal; /// dispatch sites devirtualize. /// The keyed source changeset stream. /// Builds the per-subscription orchestrator from its runtime context and emitter. -internal sealed class Orchestration( +internal sealed class CacheOrchestration( IObservable> source, Func, IObserver, TOrch> factory) where TSource : notnull @@ -38,18 +38,12 @@ internal sealed class Orchestration( private sealed class OrchestratorContext : ICacheOrchestratorContext, IDisposable { - /// - /// Initial counter value representing the source subscription itself. Source completion - /// decrements by this amount; tracked inner subscriptions add their own +1 each. - /// - private const int SourceSubscriptionWeight = 1; - 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 = SourceSubscriptionWeight; + private int _subscriptionCounter = 1; // Includes the source subscription, so starts at 1 and not 0. private int _completionEmitted; private bool _disposed; @@ -182,8 +176,7 @@ private void OnDrainComplete(bool wasReentrant) // Re-check the counter: if the orchestrator added a tracked subscription during its // OnDrainComplete (re-establishing liveness), do not complete. CAS-latch ensures // exactly one OnCompleted across any number of repeated drains seeing counter == 0. - if (Volatile.Read(ref _subscriptionCounter) == 0 && - Interlocked.CompareExchange(ref _completionEmitted, 1, 0) == 0) + if (Volatile.Read(ref _subscriptionCounter) == 0 && Interlocked.CompareExchange(ref _completionEmitted, 1, 0) == 0) { _emitter.OnCompleted(); } diff --git a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs b/src/DynamicData/Cache/Internal/CacheOrchestratorBase.cs similarity index 97% rename from src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs rename to src/DynamicData/Cache/Internal/CacheOrchestratorBase.cs index dcab297e..d6088c3b 100644 --- a/src/DynamicData/Cache/Internal/OrchestratorCacheChangeBase.cs +++ b/src/DynamicData/Cache/Internal/CacheOrchestratorBase.cs @@ -16,7 +16,7 @@ namespace DynamicData.Cache.Internal; /// 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 OrchestratorCacheChangeBase( +internal abstract class CacheOrchestratorBase( ICacheOrchestratorContext context, IObserver emitter) : ICacheOrchestrator diff --git a/src/DynamicData/Cache/Internal/FilterOnObservable.cs b/src/DynamicData/Cache/Internal/FilterOnObservable.cs index 186e5696..2a6c2c01 100644 --- a/src/DynamicData/Cache/Internal/FilterOnObservable.cs +++ b/src/DynamicData/Cache/Internal/FilterOnObservable.cs @@ -14,7 +14,7 @@ internal sealed class FilterOnObservable(IObservable> Run() => Observable.Defer(() => { - var changes = source.OrchestrateManyChanges( + var changes = source.OrchestrateChangeSets( innerFactory: (item, key) => filterFactory(item, key).DistinctUntilChanged(), onSourceChange: static (cache, change) => { diff --git a/src/DynamicData/Cache/Internal/GroupOnObservable.cs b/src/DynamicData/Cache/Internal/GroupOnObservable.cs index 1d27aa71..68614dcb 100644 --- a/src/DynamicData/Cache/Internal/GroupOnObservable.cs +++ b/src/DynamicData/Cache/Internal/GroupOnObservable.cs @@ -15,7 +15,7 @@ internal sealed class GroupOnObservable(IObservable> Run() => Observable.Using( resourceFactory: () => new DynamicGrouper(), - observableFactory: grouper => source.OrchestrateMany>( + observableFactory: grouper => source.Orchestrate>( onSourceChangeSet: (changes, context) => { foreach (var change in changes.ToConcreteType()) @@ -36,6 +36,6 @@ public IObservable> Run() => } } }, - onInner: (value, parentKey) => grouper.AddOrUpdate(parentKey, value.GroupKey, value.Item), + 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 index dda185c1..83c12002 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs @@ -5,10 +5,10 @@ namespace DynamicData.Cache.Internal; /// -/// Orchestrator contract consumed by the OrchestrateMany primitive. Implementations hold +/// Orchestrator contract consumed by the Orchestrate primitive. Implementations hold /// per-subscription state as fields and receive their /// and downstream emitter as constructor -/// arguments supplied by the factory passed to OrchestrateMany. A new orchestrator instance +/// arguments supplied by the factory passed to Orchestrate. A new orchestrator instance /// is constructed per subscription, so all state is naturally isolated. /// /// Type of items in the source changeset. diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs similarity index 58% rename from src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs rename to src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs index d54d59ab..f108a1ab 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateMany.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs @@ -9,6 +9,34 @@ namespace DynamicData.Cache.Internal; /// 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 ). Used by AutoRefresh, TransformManyAsync. +/// +/// +/// Stateless, simple per-reason logic, value output +/// lambda overload. Used by MergeMany, MergeManyItems. +/// +/// +/// Output is a cache changeset, you mutate a ChangeAwareCache per source/inner event +/// . Used by FilterOnObservable, TransformOnObservable. +/// +/// +/// Output is a merged cache changeset (inner observables themselves emit cache changesets) +/// (cache overload). Used by MergeManyChangeSets. +/// +/// +/// Output is a merged list changeset (inner observables emit list changesets) +/// (list overload). Used by MergeManyListChangeSets. +/// +/// +/// internal static partial class IntObservableCacheEx { /// @@ -29,22 +57,24 @@ internal static partial class IntObservableCacheEx /// 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 OrchestrateMany( + public static IObservable Orchestrate( this IObservable> source, Func, IObserver, TOrch> factory) where TSource : notnull where TKey : notnull where TInner : notnull where TOrch : ICacheOrchestrator => - new Orchestration(source, factory).Run(); + new CacheOrchestration(source, factory).Run(); /// - /// Convenience overload of + /// Convenience overload of /// that wraps three lambdas into an . /// The source-change lambda receives the full /// so it can call , /// , or - /// as needed. + /// as needed. The inner lambda + /// receives the downstream emitter so stateless orchestrators can forward values directly + /// without needing a class or a closure over outer state. /// /// Type of items in the source changeset. /// Type of the source changeset key. @@ -52,26 +82,26 @@ public static IObservable OrchestrateManyType delivered downstream by . /// The keyed source changeset stream. /// Invoked for each source changeset, paired with the runtime context. - /// Invoked for each value emitted by a tracked inner observable, paired with its key. - /// Invoked once per drain cycle to flush the aggregated state to the emitter. + /// Invoked for each value emitted by a tracked inner observable, paired 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 for stateless orchestrators that emit directly from . /// An observable that orchestrates source and inner activity into a single result stream. - public static IObservable OrchestrateMany( + public static IObservable Orchestrate( this IObservable> source, Action, ICacheOrchestratorContext> onSourceChangeSet, - Action onInner, - Action> onDrainComplete) + Action> onInner, + Action>? onDrainComplete = null) where TSource : notnull where TKey : notnull where TInner : notnull => - source.OrchestrateMany>( + 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) + Action> onInner, + Action>? onDrainComplete) : ICacheOrchestrator where TSource : notnull where TKey : notnull @@ -79,8 +109,8 @@ internal sealed class LambdaCacheOrchestrator( { public void OnSourceChangeSet(IChangeSet changes) => onSourceChangeSet(changes, context); - public void OnInner(TInner value, TKey key) => onInner(value, key); + public void OnInner(TInner value, TKey key) => onInner(value, key, emitter); - public void OnDrainComplete(bool isFinal, bool wasReentrant) => onDrainComplete(emitter); + public void OnDrainComplete(bool isFinal, bool wasReentrant) => onDrainComplete?.Invoke(emitter); } } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs similarity index 86% rename from src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs rename to src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs index a68bb496..dce04112 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChanges.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs @@ -12,7 +12,7 @@ internal static partial class IntObservableCacheEx /// Orchestrates per-key inner observables that drive mutations to a shared /// . Source changeset events and inner emissions /// are coalesced into a single downstream changeset per drain cycle. Specialization of - /// + /// /// for the "mirror manipulator" shape used by FilterOnObservable, TransformOnObservable, and /// similar operators where each source key contributes 0 or 1 items to the output. /// @@ -33,7 +33,7 @@ internal static partial class IntObservableCacheEx /// mutates the output cache. /// /// An observable changeset where every emission is the captured changes from one drain cycle. - public static IObservable> OrchestrateManyChanges( + public static IObservable> OrchestrateChangeSets( this IObservable> source, Func> innerFactory, Action, Change> onSourceChange, @@ -42,16 +42,16 @@ public static IObservable> OrchestrateManyChanges - source.OrchestrateMany, ChangesOrchestrator>( - (context, emitter) => new ChangesOrchestrator(context, emitter, innerFactory, onSourceChange, onInner)); + source.Orchestrate, ChangeSetOrchestrator>( + (context, emitter) => new ChangeSetOrchestrator(context, emitter, innerFactory, onSourceChange, onInner)); - internal sealed class ChangesOrchestrator( + internal sealed class ChangeSetOrchestrator( ICacheOrchestratorContext context, IObserver> emitter, Func> innerFactory, Action, Change> onSourceChange, Action, TKey, TSource, TInner> onInner) - : OrchestratorCacheChangeBase>(context, emitter) + : CacheOrchestratorBase>(context, emitter) where TSource : notnull where TKey : notnull where TInner : notnull diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs new file mode 100644 index 00000000..0157322d --- /dev/null +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs @@ -0,0 +1,192 @@ +// 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. Orchestrates per-source-key inner observables that themselves emit + /// cache changesets, merging the live set of all such inner streams into a single output + /// cache changeset. Specialization of + /// + /// for the cache-to-cache merged shape used by MergeManyChangeSets (cache) and TransformManyAsync. + /// + /// 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 Refresh on the source 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. Orchestrates per-source-key inner observables that themselves emit + /// list changesets, merging the live set of all such inner streams into a single output list + /// changeset. Specialization of + /// + /// for the cache-source-to-list-merged shape used by MergeManyChangeSets (list) in Cache/Internal/. + /// + /// 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 used when storing 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) => _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(Context.Serialize(_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) => _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(Context.Serialize(_changeSetSelector(item, key).RemoveIndex()), _equalityComparer); + _entries[key] = entry; + Context.Track(key, entry.Source); + } + } +} diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs deleted file mode 100644 index 3f48970b..00000000 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMerged.cs +++ /dev/null @@ -1,110 +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; - -namespace DynamicData.Cache.Internal; - -internal static partial class IntObservableCacheEx -{ - /// - /// Orchestrates per-source-key inner observables that themselves emit cache changesets, merging - /// the live set of all such inner streams into a single output changeset. Specialization of - /// - /// for the cache-source-to-cache-merged shape used by MergeManyCacheChangeSets, - /// MergeManyCacheChangeSetsSourceCompare, and TransformManyAsync. - /// - /// 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 Refresh on the source 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> OrchestrateManyMerged( - 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.OrchestrateMany, IChangeSet, MergedOrchestrator>( - (context, emitter) => new MergedOrchestrator(context, emitter, changeSetSelector, equalityComparer, comparer, reevalOnRefresh)); - - internal sealed class MergedOrchestrator : OrchestratorCacheChangeBase, 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) => _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(Context.Serialize(_changeSetSelector(item, key).IgnoreSameReferenceUpdate())); - _cache.AddOrUpdate(entry, key); - Context.Track(key, entry.Source); - } - } -} diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs deleted file mode 100644 index 4767b3d5..00000000 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyMergedList.cs +++ /dev/null @@ -1,91 +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.List.Internal; - -namespace DynamicData.Cache.Internal; - -internal static partial class IntObservableCacheEx -{ - /// - /// Orchestrates per-source-key inner observables that themselves emit list changesets, merging - /// the live set of all such inner streams into a single output list changeset. Specialization of - /// - /// for the cache-source-to-list-merged shape used by MergeManyListChangeSets in Cache/Internal/. - /// - /// 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 used when storing per-source-key snapshots of inner contents. - /// An observable list changeset representing the merged union of all live inner changesets. - public static IObservable> OrchestrateManyMergedList( - this IObservable> source, - Func>> changeSetSelector, - IEqualityComparer? equalityComparer = null) - where TSource : notnull - where TKey : notnull - where TDest : notnull => - source.OrchestrateMany, IChangeSet, MergedListOrchestrator>( - (context, emitter) => new MergedListOrchestrator(context, emitter, changeSetSelector, equalityComparer)); - - internal sealed class MergedListOrchestrator : OrchestratorCacheChangeBase, 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) => _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(Context.Serialize(_changeSetSelector(item, key).RemoveIndex()), _equalityComparer); - _entries[key] = entry; - Context.Track(key, entry.Source); - } - } -} diff --git a/src/DynamicData/Cache/Internal/MergeMany.cs b/src/DynamicData/Cache/Internal/MergeMany.cs index 7842ff34..63597301 100644 --- a/src/DynamicData/Cache/Internal/MergeMany.cs +++ b/src/DynamicData/Cache/Internal/MergeMany.cs @@ -2,8 +2,6 @@ // 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 sealed class MergeMany @@ -28,19 +26,20 @@ public MergeMany(IObservable> source, Func Run() => - _source.OrchestrateMany( - (context, emitter) => new Orchestrator(context, emitter, _observableSelector)); - - internal sealed class Orchestrator( - ICacheOrchestratorContext context, - IObserver emitter, - Func> selector) - : OrchestratorCacheChangeBase(context, emitter) - { - public override void OnInner(TDestination value, TKey key) => Emitter.OnNext(value); - - protected override void OnItemAdded(TObject item, TKey key) => Context.Track(key, selector(item, key)); - - protected override void OnItemRemoved(TObject item, TKey key) => Context.Untrack(key); - } + _source.Orchestrate( + onSourceChangeSet: (changes, context) => + { + foreach (var change in changes.ToConcreteType()) + { + if (change.Reason is ChangeReason.Add or ChangeReason.Update) + { + 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 74a2c83b..00000000 --- a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSets.cs +++ /dev/null @@ -1,24 +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 DynamicData.Internal; - -namespace DynamicData.Cache.Internal; - -/// -/// Operator that is similar 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() => - source.OrchestrateManyMerged(changeSetSelector, equalityComparer, comparer); -} diff --git a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs b/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs deleted file mode 100644 index 29120dcd..00000000 --- a/src/DynamicData/Cache/Internal/MergeManyCacheChangeSetsSourceCompare.cs +++ /dev/null @@ -1,76 +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 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 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() => - source.OrchestrateManyMerged( - changeSetSelector: (obj, key) => selector(obj, key).Transform(dest => new ParentChildEntry(obj, dest)), - equalityComparer: _equalityComparer, - comparer: _comparer, - reevalOnRefresh: reevalOnRefresh) - .TransformImmutable(entry => entry.Child); - - 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 74bdefe0..3486fafd 100644 --- a/src/DynamicData/Cache/Internal/MergeManyItems.cs +++ b/src/DynamicData/Cache/Internal/MergeManyItems.cs @@ -28,21 +28,21 @@ public MergeManyItems(IObservable> source, Func> Run() => - _source.OrchestrateMany, Orchestrator>( - (context, emitter) => new Orchestrator(context, emitter, _observableSelector)); - - internal sealed class Orchestrator( - ICacheOrchestratorContext context, - IObserver> emitter, - Func> selector) - : OrchestratorCacheChangeBase>(context, emitter) - { - public override void OnInner((TObject Item, TDestination Value) value, TKey key) => - Emitter.OnNext(new ItemWithValue(value.Item, value.Value)); - - protected override void OnItemAdded(TObject item, TKey key) => - Context.Track(key, selector(item, key).Select(value => (Item: item, Value: value))); - - protected override void OnItemRemoved(TObject item, TKey key) => Context.Untrack(key); - } + _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 index c0bbe733..3d17a0d7 100644 --- a/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs +++ b/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs @@ -18,5 +18,5 @@ internal sealed class MergeManyListChangeSets( where TDestination : notnull { public IObservable> Run() => - source.OrchestrateManyMergedList(selector, equalityComparer); + source.OrchestrateManyChangeSets(selector, equalityComparer); } diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index 3321f937..d6be0f8a 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -19,10 +19,10 @@ internal sealed class TransformManyAsync> Run() => - source.OrchestrateMany, IChangeSet, Orchestrator>( + source.Orchestrate, IChangeSet, Orchestrator>( (context, emitter) => new Orchestrator(context, emitter, transformer, equalityComparer, comparer, errorHandler)); - internal sealed class Orchestrator : OrchestratorCacheChangeBase, IChangeSet> + internal sealed class Orchestrator : CacheOrchestratorBase, IChangeSet> { private readonly Cache, TKey> _cache = new(); private readonly ChangeSetMergeTracker _tracker; diff --git a/src/DynamicData/Cache/Internal/TransformOnObservable.cs b/src/DynamicData/Cache/Internal/TransformOnObservable.cs index dcfcc191..54d43152 100644 --- a/src/DynamicData/Cache/Internal/TransformOnObservable.cs +++ b/src/DynamicData/Cache/Internal/TransformOnObservable.cs @@ -16,7 +16,7 @@ public IObservable> Run() => Observable.Defer(() { var cache = new ChangeAwareCache(); - return source.OrchestrateMany>( + return source.Orchestrate>( onSourceChangeSet: (changes, context) => { foreach (var change in changes.ToConcreteType()) @@ -47,7 +47,7 @@ public IObservable> Run() => Observable.Defer(() } } }, - onInner: (value, key) => cache.AddOrUpdate(value, key), + onInner: (value, key, _) => cache.AddOrUpdate(value, key), onDrainComplete: observer => { var captured = cache.CaptureChanges(); diff --git a/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs index 5fcbb9ed..1ab2ea37 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs @@ -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); } /// @@ -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); + } } From 2537e59dbf4145d4832f08f6ad8decee6cd30a72 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 14 Jun 2026 16:32:27 -0700 Subject: [PATCH 23/30] Trim verbose comments across orchestrator and queue files Targeted at comments that read like PR narration or restate standard patterns. Kept comments that document non-obvious WHY: lock and disposal ordering, completion-accounting races, subtle Rx contract behavior, and AutoRefresh's pending-refresh drop semantics. Files touched: CacheOrchestration, AutoRefresh, ICacheOrchestrator, ICacheOrchestratorContext, IntObservableCacheEx.Orchestrate / .OrchestrateChangeSets / .OrchestrateManyChangeSets, SharedDeliveryQueue. No behavioral change. Build clean. --- src/DynamicData/Cache/Internal/AutoRefresh.cs | 23 ++++----- .../Cache/Internal/CacheOrchestration.cs | 47 +++++++------------ .../Cache/Internal/ICacheOrchestrator.cs | 15 +++--- .../Internal/ICacheOrchestratorContext.cs | 11 ++--- .../IntObservableCacheEx.Orchestrate.cs | 23 ++++----- ...ObservableCacheEx.OrchestrateChangeSets.cs | 23 +++------ ...rvableCacheEx.OrchestrateManyChangeSets.cs | 18 +++---- .../Internal/SharedDeliveryQueue.cs | 14 ++---- 8 files changed, 61 insertions(+), 113 deletions(-) diff --git a/src/DynamicData/Cache/Internal/AutoRefresh.cs b/src/DynamicData/Cache/Internal/AutoRefresh.cs index 78a09cca..00a29714 100644 --- a/src/DynamicData/Cache/Internal/AutoRefresh.cs +++ b/src/DynamicData/Cache/Internal/AutoRefresh.cs @@ -26,14 +26,10 @@ public IObservable> Run() } /// - /// Forwards each source changeset to the emitter immediately. Refresh notifications from - /// per-key reevaluators are accumulated in a dictionary (latest value wins) and flushed at - /// drain end when is , otherwise via a - /// single-shot Timer armed by the first pending refresh. Update and Remove on the source drop - /// any pending refresh for that key, so a Refresh whose value has been obsoleted by a later - /// source event is never emitted. Refreshes for keys the source has touched within the same - /// drain cycle are suppressed, so a reevaluator that fires synchronously during item - /// subscription does not produce a redundant Refresh paired with the Add. + /// 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, @@ -78,10 +74,8 @@ public override void OnDrainComplete(bool isFinal, bool wasReentrant) { _sourceTouched.Clear(); - // Flush pending refreshes whenever there is no timer-based deferral active - // (unbuffered) or when this is the final drain (the timer would otherwise be - // cancelled by stream termination). The queue re-fires OnDrainComplete after any - // reentrant drain triggered by FlushPending, so a single emit per call suffices. + // Flush when unbuffered, or on the final drain (the timer would otherwise be cancelled + // by stream termination). if (isFinal || buffer is null) { FlushPending(); @@ -111,9 +105,8 @@ protected override void OnItemRefreshed(TObject item, TKey key) { _sourceTouched.Add(key); - // The source's Refresh is being forwarded synchronously in OnSourceChangeSet, so any - // pending refresh queued for this key from a prior drain is now redundant. Drop it so the - // consumer doesn't see two Refresh notifications for the same item in quick succession. + // 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); } diff --git a/src/DynamicData/Cache/Internal/CacheOrchestration.cs b/src/DynamicData/Cache/Internal/CacheOrchestration.cs index dafca3db..abaa0478 100644 --- a/src/DynamicData/Cache/Internal/CacheOrchestration.cs +++ b/src/DynamicData/Cache/Internal/CacheOrchestration.cs @@ -11,19 +11,15 @@ namespace DynamicData.Cache.Internal; /// /// Drives an against a source -/// changeset. returns an that constructs a -/// fresh per-subscription on each subscribe, so all per-subscription -/// state owned by the is recreated on every subscribe. The -/// orchestrator itself is constructed by the supplied , which receives -/// the per-subscription context and emitter; this guarantees a fresh orchestrator instance per -/// subscriber and removes the need for a separate Initialize hook. +/// changeset. A fresh per-subscription is constructed by +/// on each subscribe, with the orchestrator built by the supplied +/// , so all per-subscription state is naturally isolated. /// /// 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. +/// 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( @@ -54,15 +50,10 @@ public OrchestratorContext( { _queue = new SharedDeliveryQueue(onDrainComplete: OnDrainComplete); - // Wrap construction from the emitter sub-queue allocation through the source subscription - // so any throw on the way up releases everything we've allocated so far. Without this, an - // exception from CreateQueue, the factory, or the source subscribe leaks the queue/emitter/ - // orchestrator/source-subscription because the ctor never completes and Dispose never runs. try { - // Create the emitter sub-queue first (lowest index, drains last LIFO) so source-triggered - // sync inner emissions (higher index) deliver first and any orchestrator emit lands on the - // emitter after that work has settled. + // 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); @@ -95,8 +86,8 @@ public void Dispose() _disposed = true; - // Stop incoming work first so source/inner pumps cannot keep firing into a terminating - // queue. The queue itself is disposed afterwards to drain (or terminate) cleanly. + // Stop incoming work before disposing the queue so source/inner pumps can't fire into + // a terminating queue. _sourceSubscription.Dispose(); _innerSubscriptions.Dispose(); _queue.Dispose(); @@ -108,15 +99,15 @@ public void Track(TKey key, IObservable observable) { Debug.Assert(observable is not null, "Use Untrack(key) to remove a tracked subscription"); - // Increment before adding so the OnCompleted callback that fires when the previous subscription - // for this key is disposed does not race the counter down to zero and signal premature termination. + // 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(DecrementSubscriptionCount) fires on completion, error, AND disposal, so the counter - // always decrements. The onCompleted callback only fires on normal completion, so an inner - // subscription disposed by Track replacing it (or by Dispose) does not trigger Remove from inside. + // 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) @@ -156,11 +147,8 @@ private void OnInner(TInner value, TKey key) private void OnDrainComplete(bool wasReentrant) { - // Counter == 0 means source and every tracked inner have terminated. This is the - // authoritative source of truth for "this is the last drain"; using a separate latched - // flag races when the orchestrator calls Track during the isFinal call (counter goes - // back to 1 but a latched flag would still say true, and we'd fire OnCompleted while - // a live inner exists). + // 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 @@ -173,9 +161,8 @@ private void OnDrainComplete(bool wasReentrant) return; } - // Re-check the counter: if the orchestrator added a tracked subscription during its - // OnDrainComplete (re-establishing liveness), do not complete. CAS-latch ensures - // exactly one OnCompleted across any number of repeated drains seeing counter == 0. + // 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(); diff --git a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs index 83c12002..658c172a 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestrator.cs @@ -5,11 +5,9 @@ namespace DynamicData.Cache.Internal; /// -/// Orchestrator contract consumed by the Orchestrate primitive. Implementations hold -/// per-subscription state as fields and receive their -/// and downstream emitter as constructor -/// arguments supplied by the factory passed to Orchestrate. A new orchestrator instance -/// is constructed per subscription, so all state is naturally isolated. +/// 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. @@ -46,10 +44,9 @@ internal interface ICacheOrchestrator /// 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 queue's drain loop). Most - /// orchestrators can ignore this; it is exposed for advanced consumers that want to differentiate - /// the "I just emitted" path from a clean drain cycle. + /// 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 index 404c91c9..ce8ab4e6 100644 --- a/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs +++ b/src/DynamicData/Cache/Internal/ICacheOrchestratorContext.cs @@ -33,16 +33,13 @@ internal interface ICacheOrchestratorContext void Untrack(TKey key); /// - /// Wraps with the shared delivery queue's synchronization gate so - /// that any operators chained downstream (e.g. side-effecting Do calls, time-based - /// buffering of values that will be re-emitted) run under the same serialization that source - /// and inner notifications already enjoy. + /// 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. The downstream stream can complete even while a Serialize-wrapped - /// subscription is still active. Use for subscriptions whose lifetime - /// should keep the stream alive. + /// completion accounting: downstream can complete while it is still active. /// /// Value type of the observable being serialized. /// The observable to wrap. diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs index f108a1ab..e2843f66 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs @@ -50,10 +50,7 @@ internal static partial class IntObservableCacheEx /// 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 over the concrete type so dispatch - /// sites devirtualize. C# generic inference resolves this from the factory's return type. - /// + /// 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. @@ -68,22 +65,18 @@ public static IObservable Orchestrate /// Convenience overload of - /// that wraps three lambdas into an . - /// The source-change lambda receives the full - /// so it can call , - /// , or - /// as needed. The inner lambda - /// receives the downstream emitter so stateless orchestrators can forward values directly - /// without needing a class or a closure over outer state. + /// 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 by . + /// Type delivered downstream. /// The keyed source changeset stream. - /// Invoked for each source changeset, paired with the runtime context. - /// Invoked for each value emitted by a tracked inner observable, paired 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 for stateless orchestrators that emit directly from . + /// 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, diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs index dce04112..8d5b5e84 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs @@ -10,28 +10,19 @@ internal static partial class IntObservableCacheEx { /// /// Orchestrates per-key inner observables that drive mutations to a shared - /// . Source changeset events and inner emissions - /// are coalesced into a single downstream changeset per drain cycle. Specialization of - /// - /// for the "mirror manipulator" shape used by FilterOnObservable, TransformOnObservable, and - /// similar operators where each source key contributes 0 or 1 items to the output. + /// . 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. Used by FilterOnObservable, TransformOnObservable. /// /// Type of items in the source changeset. - /// Type of the source changeset key (also the key of the output 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. Receives the output cache and the source change; the caller - /// decides how (and whether) the source event mutates the output cache. Always invoked before - /// the corresponding inner subscription is created or torn down. - /// - /// - /// Invoked once per inner emission. Receives the output cache, the source key, the current source - /// item, and the value emitted by the inner observable. The caller decides how the inner emission - /// mutates the output cache. - /// + /// 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, diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs index 0157322d..6418b803 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs @@ -10,11 +10,8 @@ namespace DynamicData.Cache.Internal; internal static partial class IntObservableCacheEx { /// - /// Cache-shape overload. Orchestrates per-source-key inner observables that themselves emit - /// cache changesets, merging the live set of all such inner streams into a single output - /// cache changeset. Specialization of - /// - /// for the cache-to-cache merged shape used by MergeManyChangeSets (cache) and TransformManyAsync. + /// Cache-shape overload. Per-source-key inner observables that emit cache changesets, merged + /// into a single output cache changeset. Used by MergeManyChangeSets (cache) and TransformManyAsync. /// /// Type of items in the source changeset. /// Type of the source changeset key. @@ -24,7 +21,7 @@ internal static partial class IntObservableCacheEx /// 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 Refresh on the source forces re-evaluation of the corresponding child entries via the tracker. + /// 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, @@ -40,18 +37,15 @@ public static IObservable> OrchestrateManyChangeSets (context, emitter) => new MergedOrchestrator(context, emitter, changeSetSelector, equalityComparer, comparer, reevalOnRefresh)); /// - /// List-shape overload. Orchestrates per-source-key inner observables that themselves emit - /// list changesets, merging the live set of all such inner streams into a single output list - /// changeset. Specialization of - /// - /// for the cache-source-to-list-merged shape used by MergeManyChangeSets (list) in Cache/Internal/. + /// List-shape overload. Per-source-key inner observables that emit list changesets, merged + /// into a single output list changeset. Used by MergeManyListChangeSets. /// /// 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 used when storing per-source-key snapshots of inner contents. + /// 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, diff --git a/src/DynamicData/Internal/SharedDeliveryQueue.cs b/src/DynamicData/Internal/SharedDeliveryQueue.cs index 0775869c..381e3035 100644 --- a/src/DynamicData/Internal/SharedDeliveryQueue.cs +++ b/src/DynamicData/Internal/SharedDeliveryQueue.cs @@ -186,10 +186,7 @@ private void DrainAll() { try { - // Tracks whether a reentrant drain ran during the prior delivery, surfaced to the - // callback so consumers can branch on the distinction. Reset to false at the start - // of each iteration (after consumption) so each callback invocation sees only the - // reentrancy that occurred during the immediately preceding delivery cycle. + // Reentrancy flag for the prior delivery cycle, reset and re-captured each iteration. var wasReentrant = false; while (true) @@ -210,8 +207,8 @@ private void DrainAll() return; } - // Capture and reset before the callback so the flag exclusively reflects reentrant - // drains triggered by _onDrainComplete itself on the next iteration. + // Snapshot before the callback so the next iteration's flag reflects only what + // happened during _onDrainComplete itself. wasReentrant = _drainReentered; _drainReentered = false; @@ -220,9 +217,8 @@ private void DrainAll() _onDrainComplete(wasReentrant); } - // Loop back if items are pending OR if a reentrant drain ran during - // _onDrainComplete (items consumed by the reentrant drain may have - // updated orchestrator state that needs another flush pass). + // Loop back if items are pending OR a reentrant drain ran during _onDrainComplete + // (which may have updated orchestrator state that needs another flush). EnterLock(); if ((_activeBits.HasAny() || _drainReentered) && !_isTerminated) From 0c69976b90d126b94590c5b5ace4f14a49a777ac Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Sun, 14 Jun 2026 16:43:20 -0700 Subject: [PATCH 24/30] Strengthen orchestrator test fixtures Five fixes from the test review: 1. OrchestrateFixture.Serialization_ParentAndChildDoNotInterleave: was vacuous (only one source push, no child emissions, callLog only contained one start/end pair). Rewritten to race source-side Refresh and child-side OnNext from two threads via Barrier, so the serialization gate actually has work to serialize. 2. OrchestrateFixture.ReentrantDrain: replaced await Task.Delay(50) with SpinWait.SpinUntil(predicate, 5s deadline). Deterministic completion check with a deadlock guard instead of a fixed wait. 3. TransformManyAsyncOrchestratorFixture.TransformerThrows: replaced await Task.Delay(20) with await trackedObs.DefaultIfEmpty(). LastOrDefaultAsync(). The deferred async lambda runs sync (throw + catch + handler-call + return Empty), so the inner completes deterministically. 4. ChangeSetOrchestratorFixture.OnDrainComplete_EmptyChangeSet_ NoEmission: new test verifying the orchestrator's empty-changeset suppression (the if (captured.Count != 0) guard). 5. MergedOrchestratorFixture / MergedListOrchestratorFixture: expanded from 2 tests each to 5/4. Added OnItemUpdated (re-tracks), MultipleKeys (independent tracking), and (cache only) OnItemRefreshed_WithoutReevalOnRefresh (negative path no-op). Did NOT add the positive reevalOnRefresh test: it can't pass in isolation because the orchestrator inspects the per-key entry's internal cache, which is only populated by the entry's source observable (driven by the real CacheOrchestration runtime, not the FakeOrchestratorContext). That path is covered by the existing integration tests in MergeManyChangeSetsCacheFixture. Also: AutoRefreshOrchestratorFixture now uses "using Microsoft.Reactive.Testing;" instead of the fully-qualified TestScheduler name. --- .../AutoRefreshOrchestratorFixture.cs | 8 +- .../Internal/ChangeSetOrchestratorFixture.cs | 18 ++++ .../Internal/MergedListOrchestratorFixture.cs | 57 ++++++++++--- .../Internal/MergedOrchestratorFixture.cs | 84 +++++++++++++++---- .../Internal/OrchestrateFixture.cs | 55 +++++++++--- .../TransformManyAsyncOrchestratorFixture.cs | 7 +- 6 files changed, 188 insertions(+), 41 deletions(-) diff --git a/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs index 0ffdd935..83dc8961 100644 --- a/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs @@ -12,6 +12,8 @@ using FluentAssertions; +using Microsoft.Reactive.Testing; + using Xunit; namespace DynamicData.Tests.Internal; @@ -79,7 +81,7 @@ public void OnDrainComplete_Buffered_NoFlushUntilTimer() { var context = new FakeOrchestratorContext>(); var emitter = new CollectingObserver>(); - var scheduler = new Microsoft.Reactive.Testing.TestScheduler(); + var scheduler = new TestScheduler(); var orchestrator = new AutoRefresh.Orchestrator( context, emitter, reEvaluator: (item, key) => new Subject(), @@ -103,7 +105,7 @@ public void OnDrainComplete_BufferedWithIsFinal_FlushesSynchronously() { var context = new FakeOrchestratorContext>(); var emitter = new CollectingObserver>(); - var scheduler = new Microsoft.Reactive.Testing.TestScheduler(); + var scheduler = new TestScheduler(); var orchestrator = new AutoRefresh.Orchestrator( context, emitter, reEvaluator: (item, key) => new Subject(), @@ -164,7 +166,7 @@ public void OnItemRefreshed_DropsPendingRefreshForKey() { var context = new FakeOrchestratorContext>(); var emitter = new CollectingObserver>(); - var scheduler = new Microsoft.Reactive.Testing.TestScheduler(); + var scheduler = new TestScheduler(); var orchestrator = new AutoRefresh.Orchestrator( context, emitter, reEvaluator: (item, key) => new Subject(), diff --git a/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs index fac6b0e9..7a538acb 100644 --- a/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs @@ -82,4 +82,22 @@ public void OnItemRemoved_UntracksKey() 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(), + // Source-change callback intentionally does nothing, so the ChangeAwareCache stays 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/MergedListOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs index 49269ec9..97c8ef7c 100644 --- a/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs @@ -2,6 +2,7 @@ // 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; @@ -15,22 +16,25 @@ namespace DynamicData.Tests.Internal; /// /// In-isolation tests for , -/// the cache-source-to-list-merged orchestrator. Verifies per-source-key list tracking and removal -/// semantics through the ChangeSetMergeTracker. +/// the cache-source-to-list-merged orchestrator. /// 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 = new IntObservableCacheEx.MergedListOrchestrator( - context, emitter, - changeSetSelector: (item, key) => Observable.Empty>(), - equalityComparer: null); + 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); @@ -45,14 +49,47 @@ public void OnItemRemoved_UntracksAndRemovesPriorItemsFromTracker() { var context = new FakeOrchestratorContext>(); var emitter = new CollectingObserver>(); - var orchestrator = new IntObservableCacheEx.MergedListOrchestrator( - context, emitter, - changeSetSelector: (item, key) => Observable.Empty>(), - equalityComparer: null); + 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 index 62013c9d..bd095b40 100644 --- a/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs @@ -2,6 +2,7 @@ // 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; @@ -15,24 +16,28 @@ namespace DynamicData.Tests.Internal; /// /// In-isolation tests for , -/// the orchestrator behind OrchestrateManyChangeSets. Verifies per-source-key tracking, update -/// semantics (new tracked + prior items removed from tracker), and the reevalOnRefresh flag. +/// the orchestrator behind OrchestrateManyChangeSets. /// 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 = new IntObservableCacheEx.MergedOrchestrator( - context, emitter, - changeSetSelector: (item, key) => Observable.Empty>(), - equalityComparer: null, - comparer: null, - reevalOnRefresh: false); + 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); @@ -48,16 +53,67 @@ public void OnItemRemoved_UntracksAndDropsItemsFromTracker() { var context = new FakeOrchestratorContext>(); var emitter = new CollectingObserver>(); - var orchestrator = new IntObservableCacheEx.MergedOrchestrator( - context, emitter, - changeSetSelector: (item, key) => Observable.Empty>(), - equalityComparer: null, - comparer: null, - reevalOnRefresh: false); + 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 index 7f91401d..600a497d 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateFixture.cs @@ -306,25 +306,57 @@ public void SourceAlreadyErrored_PropagatesError() } [Fact] - public void Serialization_ParentAndChildDoNotInterleave() + 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: key => new Subject(), + 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(_rand.Number(SeedMin, SeedMax), "item")); + // Bootstrap: one Add so the child subject is subscribed and stable for the race. + source.AddOrUpdate(new TestItem(1, "init")); + childSubject.Should().NotBeNull(); + + // Race source-side Refresh and child-side OnNext from two threads. + 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); - // Start/end pairs should not interleave - for (var i = 0; i + 1 < callLog.Count; i += 2) + // Every start/end pair must match. If parent and child callbacks ever interleaved, a P-start + // would be followed by C-start (or vice versa) instead of its own P-end. + lock (callLog) { - var prefix = callLog[i].Split('-')[0]; - callLog[i + 1].Should().StartWith(prefix, "operations should not interleave"); + 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"); + } } } @@ -427,9 +459,12 @@ public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstrea await Task.WhenAll(producers); - // Drain may finish on a producer thread after WhenAll observes the task completing, so - // settle briefly to let any in-flight delivery finish. - await Task.Delay(50); + // Spin until every emission has reached OnInner. Deterministic upper bound guards against + // a real deadlock; under healthy conditions this returns quickly. + 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) { diff --git a/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs index c31c8599..483f50ac 100644 --- a/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs @@ -102,11 +102,10 @@ public async Task TransformerThrows_RoutesThroughErrorHandler() orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }); - // The deferred transform subscribes when we drive the tracked observable; subscribe now and - // settle the asynchronous defer to surface the error to the handler. + // Subscribe and await the deferred inner to its terminal. The async lambda runs synchronously + // (sync throw -> catch -> handler -> return Empty), so the inner completes deterministically. var trackedObs = context.Tracked[1]; - using var sub = trackedObs.Subscribe(); - await Task.Delay(20); + await trackedObs.DefaultIfEmpty().LastOrDefaultAsync(); capturedError.Should().NotBeNull(); capturedError!.Exception!.Message.Should().Be("transformer-broke"); From 9e45d5c18f654d3f802ebdba875864a96e1c0805 Mon Sep 17 00:00:00 2001 From: dwcullop Date: Sun, 14 Jun 2026 17:09:40 -0700 Subject: [PATCH 25/30] Address PR feedback: drop redundant Serialize, document MergeMany behavior change Two issues raised by the PR reviewer: 1. Double queue wrap in OrchestrateManyChangeSets / TransformManyAsync. ChangeSetCache (and ClonedListChangeSet) were being constructed over Context.Serialize(inner), and then entry.Source was passed to Context.Track, which also wraps with SynchronizeSafe. Each per-key inner emission was passing through two SharedDeliveryQueue sub-queues, doubling allocations and queue traffic. Drop the explicit Serialize and let Track do it once. 2. Stale XML docs on MergeMany. With the migration to the orchestrator, errors from per-item observables now terminate the merged stream rather than being swallowed. Updated the docs on MergeMany itself plus the four operators that route through it (WhenAnyPropertyChanged, WhenPropertyChanged, WhenValueChanged, AutoRefreshOnObservable) to describe the new behavior and call out the prior swallowing behavior. --- .../IntObservableCacheEx.OrchestrateManyChangeSets.cs | 6 ++++-- src/DynamicData/Cache/Internal/TransformManyAsync.cs | 2 +- .../Cache/ObservableCacheEx.AutoRefreshOnObservable.cs | 2 +- src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs | 3 ++- .../Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs | 6 +++--- .../Cache/ObservableCacheEx.WhenPropertyChanged.cs | 8 ++++---- .../Cache/ObservableCacheEx.WhenValueChanged.cs | 8 ++++---- 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs index 6418b803..61080da6 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs @@ -121,7 +121,9 @@ protected override void OnItemRefreshed(TSource item, TKey key) private void SubscribeChild(TSource item, TKey key) { - var entry = new ChangeSetCache(Context.Serialize(_changeSetSelector(item, key).IgnoreSameReferenceUpdate())); + // Track applies queue serialization to entry.Source, so the inner stream (including + // ChangeSetCache.Clone via Do) does not need to be wrapped in Context.Serialize first. + var entry = new ChangeSetCache(_changeSetSelector(item, key).IgnoreSameReferenceUpdate()); _cache.AddOrUpdate(entry, key); Context.Track(key, entry.Source); } @@ -178,7 +180,7 @@ protected override void OnItemRemoved(TSource item, TKey key) private void SubscribeChild(TSource item, TKey key) { - var entry = new ClonedListChangeSet(Context.Serialize(_changeSetSelector(item, key).RemoveIndex()), _equalityComparer); + var entry = new ClonedListChangeSet(_changeSetSelector(item, key).RemoveIndex(), _equalityComparer); _entries[key] = entry; Context.Track(key, entry.Source); } diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index d6be0f8a..86585526 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -72,7 +72,7 @@ protected override void OnItemRemoved(TSource item, TKey key) private void SubscribeChild(TSource item, TKey key) { - var entry = new ChangeSetCache(Context.Serialize(BuildInner(item, key))); + var entry = new ChangeSetCache(BuildInner(item, key)); _cache.AddOrUpdate(entry, key); Context.Track(key, entry.Source); } diff --git a/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs b/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs index 26ac5188..5de96a18 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs @@ -59,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. Prior to v9 they were silently ignored; as of v9 they propagate, matching the change made to . /// 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 b3c290d3..490026e3 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs @@ -49,8 +49,9 @@ 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: Prior to v9, errors from child observables were silently swallowed and the offending child was unsubscribed while the merged stream continued. As of v9 child errors are forwarded, matching the semantics of standard Observable.Merge. /// Worth noting: The output is a plain , not a changeset stream. If you need merged changesets, use instead. /// /// or is null. diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs index 15c3196d..4c38c5a7 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,8 +46,9 @@ 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. /// + /// Worth noting: Prior to v9, errors from individual property subscriptions were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs index 9efbb279..9cdba51c 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,8 +47,9 @@ 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. /// + /// Worth noting: Prior to v9, per-item property subscription errors were silently ignored. As of v9 they propagate via the underlying . /// /// public static IObservable> WhenPropertyChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs index b27e31c4..0527b5e1 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,8 +47,9 @@ 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. /// + /// Worth noting: Prior to v9, per-item errors were silently ignored. As of v9 they propagate via the underlying . /// /// /// From 4f99e3cbcccc2a1c2d85bfd541284622ae64dec2 Mon Sep 17 00:00:00 2001 From: dwcullop Date: Sun, 14 Jun 2026 17:28:35 -0700 Subject: [PATCH 26/30] Make ChangeSetCache and ClonedListChangeSet passive The reviewer's double-wrap concern was a symptom of ChangeSetCache (and ClonedListChangeSet) doing .Do(Cache.Clone) inside their constructor. That forced every caller to pre-serialize the source observable so the mirror stayed coherent with whatever else read it, even when the caller already had its own serialization layer downstream. The orchestrator was having to do it twice: once via Context.Serialize to protect the mirror, and again via Context.Track to dispatch OnInner on the queue thread. Dropping just one wrap raced OnItemRemoved's enumeration of the mirror against concurrent inner emissions. Make both types passive: Source returns the raw observable, and a new Process(changes) method updates the mirror. Each caller now drives the mirror at the point in its pipeline where delivery is already serialized: - Cache and List MergeChangeSets call Process inside the MergeMany Do(). - MergeManyCacheChangeSets and MergeManyListChangeSets do the same. - CacheOrchestration-based operators (MergedOrchestrator, MergedListOrchestrator, TransformManyAsync) call Process in OnInner, on the queue thread, alongside the tracker call. They no longer need Context.Serialize. Single SynchronizeSafe wrap per per-key inner subscription. Mirror mutation happens on the same thread that reads it. --- .../Cache/Internal/ChangeSetCache.cs | 19 ++++++++------ ...rvableCacheEx.OrchestrateManyChangeSets.cs | 25 ++++++++++++++++--- .../Cache/Internal/MergeChangeSets.cs | 2 +- .../Cache/Internal/TransformManyAsync.cs | 11 +++++++- .../List/Internal/ClonedListChangeSet.cs | 18 +++++++------ .../List/Internal/MergeChangeSets.cs | 2 +- .../List/Internal/MergeManyCacheChangeSets.cs | 2 +- .../List/Internal/MergeManyListChangeSets.cs | 2 +- 8 files changed, 57 insertions(+), 24 deletions(-) diff --git a/src/DynamicData/Cache/Internal/ChangeSetCache.cs b/src/DynamicData/Cache/Internal/ChangeSetCache.cs index d63a93be..5e7b2cb1 100644 --- a/src/DynamicData/Cache/Internal/ChangeSetCache.cs +++ b/src/DynamicData/Cache/Internal/ChangeSetCache.cs @@ -2,23 +2,26 @@ // 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 together with an aggregated mirror of its content. +/// Callers drive the mirror by invoking for each changeset they +/// observe through , on whatever thread they've already serialized +/// delivery to. The mirror is not updated automatically, so consumers do not need to +/// pre-wrap in a synchronization layer for the sole purpose +/// of keeping the mirror coherent. /// /// 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; + + /// Applies to the aggregated . + public void Process(IChangeSet changes) => Cache.Clone(changes); } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs index 61080da6..af950c46 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs @@ -82,7 +82,15 @@ public MergedOrchestrator( _tracker = new ChangeSetMergeTracker(() => _cache.Items, comparer, equalityComparer); } - public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); + 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); @@ -121,8 +129,8 @@ protected override void OnItemRefreshed(TSource item, TKey key) private void SubscribeChild(TSource item, TKey key) { - // Track applies queue serialization to entry.Source, so the inner stream (including - // ChangeSetCache.Clone via Do) does not need to be wrapped in Context.Serialize first. + // ChangeSetCache is passive; its Cache mirror is updated from OnInner on the queue + // thread. Track wraps the inner with the single SynchronizeSafe layer it needs. var entry = new ChangeSetCache(_changeSetSelector(item, key).IgnoreSameReferenceUpdate()); _cache.AddOrUpdate(entry, key); Context.Track(key, entry.Source); @@ -150,7 +158,15 @@ public MergedListOrchestrator( _equalityComparer = equalityComparer; } - public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); + 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); @@ -180,6 +196,7 @@ protected override void OnItemRemoved(TSource item, TKey key) private void SubscribeChild(TSource item, TKey key) { + // ClonedListChangeSet is passive; the mirror updates from OnInner on the queue thread. 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 ebf4b22f..6ab9c376 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).Do(static _ => { }, observer.OnError)) .SubscribeSafe( changes => changeTracker.ProcessChangeSet(changes, observer), observer.OnError, diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index 86585526..61707b6a 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -43,7 +43,15 @@ public Orchestrator( _tracker = new ChangeSetMergeTracker(() => _cache.Items, comparer, equalityComparer); } - public override void OnInner(IChangeSet child, TKey parentKey) => _tracker.ProcessChangeSet(child, null); + 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); @@ -72,6 +80,7 @@ protected override void OnItemRemoved(TSource item, TKey key) private void SubscribeChild(TSource item, TKey key) { + // ChangeSetCache is passive; the mirror updates from OnInner on the queue thread. var entry = new ChangeSetCache(BuildInner(item, key)); _cache.AddOrUpdate(entry, key); Context.Track(key, entry.Source); diff --git a/src/DynamicData/List/Internal/ClonedListChangeSet.cs b/src/DynamicData/List/Internal/ClonedListChangeSet.cs index 3b9996a1..b9d920b5 100644 --- a/src/DynamicData/List/Internal/ClonedListChangeSet.cs +++ b/src/DynamicData/List/Internal/ClonedListChangeSet.cs @@ -2,17 +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.List.Internal; -internal sealed class ClonedListChangeSet +/// +/// Holds an observable list changeset together with an aggregated mirror of its content. +/// Callers drive the mirror by invoking for each changeset they +/// observe through . See +/// for the rationale. +/// +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; + + /// Applies to the aggregated . + 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 480c69b3..3726b96f 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().Do(static _ => { }, observer.OnError)) .Subscribe( changes => changeTracker.ProcessChangeSet(changes, observer), observer.OnError, diff --git a/src/DynamicData/List/Internal/MergeManyCacheChangeSets.cs b/src/DynamicData/List/Internal/MergeManyCacheChangeSets.cs index 3b63ea23..a7be674b 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 482c4f46..edab5c1f 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, From acaa9f353de5c951667cbeaef132ca32847f4121 Mon Sep 17 00:00:00 2001 From: dwcullop Date: Sun, 14 Jun 2026 17:46:39 -0700 Subject: [PATCH 27/30] Trim out-of-scope comments from passive ChangeSetCache refactor --- src/DynamicData/Cache/Internal/ChangeSetCache.cs | 9 ++------- .../IntObservableCacheEx.OrchestrateManyChangeSets.cs | 3 --- src/DynamicData/Cache/Internal/TransformManyAsync.cs | 1 - src/DynamicData/List/Internal/ClonedListChangeSet.cs | 7 ++----- 4 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/DynamicData/Cache/Internal/ChangeSetCache.cs b/src/DynamicData/Cache/Internal/ChangeSetCache.cs index 5e7b2cb1..a5a2a6b2 100644 --- a/src/DynamicData/Cache/Internal/ChangeSetCache.cs +++ b/src/DynamicData/Cache/Internal/ChangeSetCache.cs @@ -5,12 +5,8 @@ namespace DynamicData.Cache.Internal; /// -/// Holds an observable changeset together with an aggregated mirror of its content. -/// Callers drive the mirror by invoking for each changeset they -/// observe through , on whatever thread they've already serialized -/// delivery to. The mirror is not updated automatically, so consumers do not need to -/// pre-wrap in a synchronization layer for the sole purpose -/// of keeping the mirror coherent. +/// Holds an observable changeset alongside an aggregated mirror cache. +/// applies a changeset to . /// /// ChangeSet Object Type. /// ChangeSet Key Type. @@ -22,6 +18,5 @@ internal sealed class ChangeSetCache(IObservable> Source { get; } = source; - /// Applies to the aggregated . public void Process(IChangeSet changes) => Cache.Clone(changes); } diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs index af950c46..e04d53a1 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs @@ -129,8 +129,6 @@ protected override void OnItemRefreshed(TSource item, TKey key) private void SubscribeChild(TSource item, TKey key) { - // ChangeSetCache is passive; its Cache mirror is updated from OnInner on the queue - // thread. Track wraps the inner with the single SynchronizeSafe layer it needs. var entry = new ChangeSetCache(_changeSetSelector(item, key).IgnoreSameReferenceUpdate()); _cache.AddOrUpdate(entry, key); Context.Track(key, entry.Source); @@ -196,7 +194,6 @@ protected override void OnItemRemoved(TSource item, TKey key) private void SubscribeChild(TSource item, TKey key) { - // ClonedListChangeSet is passive; the mirror updates from OnInner on the queue thread. var entry = new ClonedListChangeSet(_changeSetSelector(item, key).RemoveIndex(), _equalityComparer); _entries[key] = entry; Context.Track(key, entry.Source); diff --git a/src/DynamicData/Cache/Internal/TransformManyAsync.cs b/src/DynamicData/Cache/Internal/TransformManyAsync.cs index 61707b6a..70aaf0b3 100644 --- a/src/DynamicData/Cache/Internal/TransformManyAsync.cs +++ b/src/DynamicData/Cache/Internal/TransformManyAsync.cs @@ -80,7 +80,6 @@ protected override void OnItemRemoved(TSource item, TKey key) private void SubscribeChild(TSource item, TKey key) { - // ChangeSetCache is passive; the mirror updates from OnInner on the queue thread. var entry = new ChangeSetCache(BuildInner(item, key)); _cache.AddOrUpdate(entry, key); Context.Track(key, entry.Source); diff --git a/src/DynamicData/List/Internal/ClonedListChangeSet.cs b/src/DynamicData/List/Internal/ClonedListChangeSet.cs index b9d920b5..4e321bfb 100644 --- a/src/DynamicData/List/Internal/ClonedListChangeSet.cs +++ b/src/DynamicData/List/Internal/ClonedListChangeSet.cs @@ -5,10 +5,8 @@ namespace DynamicData.List.Internal; /// -/// Holds an observable list changeset together with an aggregated mirror of its content. -/// Callers drive the mirror by invoking for each changeset they -/// observe through . See -/// for the rationale. +/// 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 @@ -17,6 +15,5 @@ internal sealed class ClonedListChangeSet(IObservable> Source { get; } = source; - /// Applies to the aggregated . public void Process(IChangeSet changes) => List.Clone(changes, equalityComparer); } From a29d1b0daec2819f887356858be10460d18442fc Mon Sep 17 00:00:00 2001 From: dwcullop Date: Sun, 14 Jun 2026 18:04:02 -0700 Subject: [PATCH 28/30] Propagate per-item observable errors from list MergeMany and remove no-op error forwarders The list MergeMany was actively swallowing inner errors with an empty onError handler. Forward the error to the merged observer so children that fail terminate the merged stream, matching the cache MergeMany behavior change. The cache and list MergeChangeSets pipelines had .Do(static _ => { }, observer.OnError) sitting after the MergeMany projection. With Rx already propagating inner errors through MergeMany to the downstream SubscribeSafe, that .Do was a no-op that just looked like custom error handling. Remove it. List-side docs updated for MergeMany, WhenAnyPropertyChanged, WhenPropertyChanged, WhenValueChanged, AutoRefreshOnObservable, and FilterOnObservable to describe the new propagation contract. The list MergeManyFixture had a test asserting the old swallow behavior (MergedStreamCompletesIfLastItemFails). Replaced with MergedStreamFailsIfChildFails, matching the cache fixture's equivalent. --- src/DynamicData.Tests/List/MergeManyFixture.cs | 16 +++++++--------- .../Cache/Internal/MergeChangeSets.cs | 2 +- src/DynamicData/List/Internal/MergeChangeSets.cs | 2 +- src/DynamicData/List/Internal/MergeMany.cs | 2 +- .../ObservableListEx.AutoRefreshOnObservable.cs | 2 ++ .../List/ObservableListEx.FilterOnObservable.cs | 2 ++ .../List/ObservableListEx.MergeMany.cs | 2 ++ .../ObservableListEx.WhenAnyPropertyChanged.cs | 1 + .../List/ObservableListEx.WhenPropertyChanged.cs | 1 + .../List/ObservableListEx.WhenValueChanged.cs | 3 +++ 10 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/DynamicData.Tests/List/MergeManyFixture.cs b/src/DynamicData.Tests/List/MergeManyFixture.cs index 997e91f9..63a75f1c 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/Cache/Internal/MergeChangeSets.cs b/src/DynamicData/Cache/Internal/MergeChangeSets.cs index 6ab9c376..b9ef506c 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(mc.Process).Do(static _ => { }, observer.OnError)) + .MergeMany(mc => mc.Source.Do(mc.Process)) .SubscribeSafe( changes => changeTracker.ProcessChangeSet(changes, observer), observer.OnError, diff --git a/src/DynamicData/List/Internal/MergeChangeSets.cs b/src/DynamicData/List/Internal/MergeChangeSets.cs index 3726b96f..b6aea738 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.Do(clonedList.Process).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 65b23806..40cb63e2 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/ObservableListEx.AutoRefreshOnObservable.cs b/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs index dc3ede92..5dca78ac 100644 --- a/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs +++ b/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs @@ -47,7 +47,9 @@ 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. /// + /// Worth noting: Prior to v9, per-item re-evaluator errors were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs b/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs index bd49bf1d..d40d2635 100644 --- a/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs +++ b/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs @@ -50,7 +50,9 @@ 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. /// + /// Worth noting: Prior to v9, errors from per-item filter observables were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.MergeMany.cs b/src/DynamicData/List/ObservableListEx.MergeMany.cs index 55dfa4f1..10f6e902 100644 --- a/src/DynamicData/List/ObservableListEx.MergeMany.cs +++ b/src/DynamicData/List/ObservableListEx.MergeMany.cs @@ -41,7 +41,9 @@ 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. /// + /// Worth noting: Prior to v9, errors from per-item observables were silently swallowed and the offending item was unsubscribed while the merged stream continued. As of v9 child errors propagate, matching the semantics of standard Observable.Merge. /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs b/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs index a6529e3f..1aa43c99 100644 --- a/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs +++ b/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs @@ -35,6 +35,7 @@ public static partial class ObservableListEx /// is . /// /// Implemented via . Subscriptions are managed per item: created on add, disposed on remove. + /// Worth noting: Prior to v9, errors from individual property subscriptions were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs b/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs index 4a91bd03..52e19e3f 100644 --- a/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs +++ b/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs @@ -37,6 +37,7 @@ public static partial class ObservableListEx /// or is . /// /// Implemented via . + /// Worth noting: Prior to v9, per-item property subscription errors were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs b/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs index 0328218f..0e6b0a97 100644 --- a/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs +++ b/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs @@ -35,6 +35,9 @@ public static partial class ObservableListEx /// When (default), the current value is emitted immediately upon subscribing to each item. /// An observable emitting the property value whenever it changes on any tracked item. /// or is . + /// + /// Worth noting: Prior to v9, per-item errors were silently ignored. As of v9 they propagate via the underlying . + /// /// /// /// From 527d0ab705d954d6f2a0bbf7ca3b0a3689982b5b Mon Sep 17 00:00:00 2001 From: dwcullop Date: Sun, 14 Jun 2026 18:23:44 -0700 Subject: [PATCH 29/30] Strip PR-narration and verbose noise from comments Removed XML and inline comments across new and modified files that won't make sense after this PR merges or that narrate test/code flow without adding maintenance value: - Test fixture class summaries that just restated the SUT name - Inline test-flow narration (Arrange/Setup-style markers) - 'Prior to v9' release-note bullets on public docs (current behavior is already documented; release notes go in release notes) - 'Used by FooOperator, BarOperator' callouts in internal API docs - 'Unlike MergeMany, child errors are NOT swallowed' comparison on MergeManyChangeSets (no longer accurate now that MergeMany propagates) - The 'C1 fix' regression-test summary on SharedDeliveryQueueFixture - 'legacy CPS-subclass shape' and similar pre-orchestrator-refactor refs Kept comments that explain non-obvious WHY for the code at hand: lock ordering, race-condition rationale, subtle Rx behavior. --- .../AutoRefreshOrchestratorFixture.cs | 19 ------ .../Internal/ChangeSetOrchestratorFixture.cs | 6 -- .../LambdaCacheOrchestratorFixture.cs | 61 ++++++++++++------- .../Internal/MergedListOrchestratorFixture.cs | 4 -- .../Internal/MergedOrchestratorFixture.cs | 4 -- .../Internal/OrchestrateFixture.cs | 52 +--------------- .../Internal/SharedDeliveryQueueFixture.cs | 14 ----- .../TransformManyAsyncOrchestratorFixture.cs | 8 --- .../Utilities/CollectingObserver.cs | 4 +- .../Utilities/FakeOrchestratorContext.cs | 16 +---- .../Cache/Internal/CacheOrchestration.cs | 5 +- .../Cache/Internal/CacheOrchestratorBase.cs | 4 -- .../IntObservableCacheEx.Orchestrate.cs | 10 +-- ...ObservableCacheEx.OrchestrateChangeSets.cs | 2 +- ...rvableCacheEx.OrchestrateManyChangeSets.cs | 4 +- ...servableCacheEx.AutoRefreshOnObservable.cs | 2 +- .../Cache/ObservableCacheEx.MergeMany.cs | 1 - .../ObservableCacheEx.MergeManyChangeSets.cs | 2 +- ...bservableCacheEx.WhenAnyPropertyChanged.cs | 1 - .../ObservableCacheEx.WhenPropertyChanged.cs | 1 - .../ObservableCacheEx.WhenValueChanged.cs | 1 - .../Internal/SharedDeliveryQueue.cs | 6 +- ...bservableListEx.AutoRefreshOnObservable.cs | 1 - .../ObservableListEx.FilterOnObservable.cs | 1 - .../List/ObservableListEx.MergeMany.cs | 1 - ...ObservableListEx.WhenAnyPropertyChanged.cs | 1 - .../ObservableListEx.WhenPropertyChanged.cs | 1 - .../List/ObservableListEx.WhenValueChanged.cs | 3 - 28 files changed, 58 insertions(+), 177 deletions(-) diff --git a/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs index 83dc8961..acab4204 100644 --- a/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/AutoRefreshOrchestratorFixture.cs @@ -18,12 +18,6 @@ namespace DynamicData.Tests.Internal; -/// -/// In-isolation tests for . Covers -/// per-key refresh accumulation, source-touched suppression (a reevaluator emission that fires -/// synchronously during item subscription must not produce a redundant Refresh paired with the Add), -/// and the buffered vs. unbuffered drain-flush policy including the isFinal synchronous flush. -/// public sealed class AutoRefreshOrchestratorFixture { private sealed record Item(int Id); @@ -41,13 +35,10 @@ public void OnDrainComplete_Unbuffered_FlushesPendingRefreshes() var item = new Item(1); orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); - // The source forwards the original changeset directly: emitter.Values.Should().HaveCount(1); - // Clear sourceTouched by draining once with no pending state. orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); - // After the source-touched window closes, an inner refresh becomes a real pending refresh. orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); @@ -67,7 +58,6 @@ public void OnInner_KeyInSourceTouched_SuppressesRefresh() scheduler: null); var item = new Item(1); - // Same source drain: Add fires (marks key as sourceTouched), then a synchronous inner refresh. orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); @@ -143,7 +133,6 @@ public void OnItemRemoved_DropsPendingRefreshForKey() var preInnerCount = emitter.Values.Count; orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); - // Source removes the item BEFORE the drain flushes; pending refresh must be dropped. orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Remove, 1, item) }); orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); @@ -174,25 +163,17 @@ public void OnItemRefreshed_DropsPendingRefreshForKey() scheduler: scheduler); var item = new Item(1); - // Establish the item and let sourceTouched clear so a subsequent inner emission counts as pending. orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, item) }); orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); - // Queue a pending refresh from the inner, then drain with timer NOT advanced so it stays pending. orchestrator.OnInner(new Change(ChangeReason.Refresh, 1, item), 1); orchestrator.OnDrainComplete(isFinal: false, wasReentrant: false); var preSourceRefreshCount = emitter.Values.Count; - // Source emits Refresh(K) in a new drain. The pending refresh from the inner is now redundant - // because the source's Refresh is forwarded immediately. Drop the pending so the consumer - // does not see two Refresh notifications for the same item. orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Refresh, 1, item) }); - // isFinal=true forces a synchronous flush of any remaining pending entries. orchestrator.OnDrainComplete(isFinal: true, wasReentrant: false); - // Expect exactly ONE additional emission (the source-forwarded Refresh). Without the - // DropPending in OnItemRefreshed, the buffered flush would emit a redundant second one. 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/ChangeSetOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs index 7a538acb..7c20f523 100644 --- a/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/ChangeSetOrchestratorFixture.cs @@ -14,11 +14,6 @@ namespace DynamicData.Tests.Internal; -/// -/// In-isolation tests for , -/// the orchestrator behind OrchestrateChangeSets. Verifies that onSourceChange and onInner -/// callbacks fire for the right reasons and that drain-end captures and emits accumulated state. -/// public sealed class ChangeSetOrchestratorFixture { private sealed record Source(int Id); @@ -91,7 +86,6 @@ public void OnDrainComplete_EmptyChangeSet_NoEmission() var orchestrator = new IntObservableCacheEx.ChangeSetOrchestrator( context, emitter, innerFactory: (item, key) => Observable.Empty(), - // Source-change callback intentionally does nothing, so the ChangeAwareCache stays empty. onSourceChange: (cache, change) => { }, onInner: (cache, key, item, value) => { }); diff --git a/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs index f9c0ea71..a43617dd 100644 --- a/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/LambdaCacheOrchestratorFixture.cs @@ -14,63 +14,80 @@ namespace DynamicData.Tests.Internal; -/// -/// In-isolation smoke tests for . -/// Verifies that calls forward to the captured lambdas and that the Track callback is wired to the -/// supplied context. Most production behavior is covered through the lambda-overload tests in -/// OrchestrateFixture; these tests confirm the forwarding contract in isolation. -/// public sealed class LambdaCacheOrchestratorFixture { private sealed record Item(int Id); [Fact] - public void OnSourceChangeSet_ForwardsToLambdaWithContext() + public void OnSourceChangeSet_ForwardsChangesToLambda() { var context = new FakeOrchestratorContext(); var emitter = new CollectingObserver(); var receivedChanges = new List>(); - ICacheOrchestratorContext? receivedContext = null; var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( context, emitter, - onSourceChangeSet: (changes, ctx) => - { - receivedChanges.Add(changes); - receivedContext = ctx; - }, + 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().HaveCount(1); - receivedChanges[0].Should().BeSameAs(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_ForwardsToLambdaWithEmitter() + public void OnInner_ForwardsValueAndKeyToLambda() { var context = new FakeOrchestratorContext(); var emitter = new CollectingObserver(); var received = new List<(string Value, int Key)>(); - IObserver? receivedEmitter = null; var orchestrator = new IntObservableCacheEx.LambdaCacheOrchestrator( context, emitter, onSourceChangeSet: (_, _) => { }, - onInner: (v, k, em) => - { - received.Add((v, k)); - receivedEmitter = em; - }, + 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"); } diff --git a/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs index 97c8ef7c..609871f2 100644 --- a/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/MergedListOrchestratorFixture.cs @@ -14,10 +14,6 @@ namespace DynamicData.Tests.Internal; -/// -/// In-isolation tests for , -/// the cache-source-to-list-merged orchestrator. -/// public sealed class MergedListOrchestratorFixture { private sealed record Item(int Id); diff --git a/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs index bd095b40..cdfc96a3 100644 --- a/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/MergedOrchestratorFixture.cs @@ -14,10 +14,6 @@ namespace DynamicData.Tests.Internal; -/// -/// In-isolation tests for , -/// the orchestrator behind OrchestrateManyChangeSets. -/// public sealed class MergedOrchestratorFixture { private sealed record Item(int Id, string Tag); diff --git a/src/DynamicData.Tests/Internal/OrchestrateFixture.cs b/src/DynamicData.Tests/Internal/OrchestrateFixture.cs index 600a497d..284fba29 100644 --- a/src/DynamicData.Tests/Internal/OrchestrateFixture.cs +++ b/src/DynamicData.Tests/Internal/OrchestrateFixture.cs @@ -22,10 +22,8 @@ namespace DynamicData.Tests.Internal; /// -/// Tests for the Orchestrate primitive's behavioral contracts: source/inner serialization, +/// Behavioral contract tests for the Orchestrate primitive: source/inner serialization, /// per-drain coalesced emission, completion counting, error propagation, and cross-cache safety. -/// Exercised via the overload because it -/// maps 1:1 to the legacy CacheParentSubscription subclass shape these tests originally targeted. /// public sealed class OrchestrateFixture { @@ -36,15 +34,8 @@ public sealed class OrchestrateFixture private readonly Randomizer _rand = new(55); - /// Test item with a typed key. private sealed record TestItem(int Key, string Value); - /// - /// Wires through Orchestrate with a fresh - /// constructed per subscription. Returns the observable plus a thunk that yields the constructed - /// orchestrator after subscribe. The factory pattern ensures per-subscription isolation and - /// matches the production Orchestrate contract. - /// private static (IObservable> Observable, Func Orchestrator) Wire( IObservable> source, Func>? childFactory = null, @@ -192,7 +183,6 @@ public void OnDrainComplete_IsFinalIsFalseUntilSourceAndAllInnersComplete() var (observable, getOrchestrator) = Wire(source.Connect(), _ => childSubject); using var sub = observable.Subscribe(observer); - // Activity while source + inner are alive source.AddOrUpdate(new TestItem(_rand.Number(SeedMin, SeedMax), "item")); childSubject.OnNext("v1"); @@ -201,14 +191,12 @@ public void OnDrainComplete_IsFinalIsFalseUntilSourceAndAllInnersComplete() orchestrator.IsFinalLog.Should().AllBeEquivalentTo(false, "isFinal must be false on every call while source and inners are still active"); - // Source completes; inner still alive — isFinal must remain false 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"); - // Final inner completes — at least one subsequent OnDrainComplete must observe isFinal=true var preInnerCompleteCount = orchestrator.IsFinalLog.Count; childSubject.OnCompleted(); orchestrator.IsFinalLog.Skip(preInnerCompleteCount).Should().Contain(true, @@ -325,11 +313,9 @@ public async Task Serialization_ParentAndChildDoNotInterleave() onChild: () => { lock (callLog) callLog.Add("C-start"); Thread.Sleep(1); lock (callLog) callLog.Add("C-end"); }); using var sub = observable.Subscribe(observer); - // Bootstrap: one Add so the child subject is subscribed and stable for the race. source.AddOrUpdate(new TestItem(1, "init")); childSubject.Should().NotBeNull(); - // Race source-side Refresh and child-side OnNext from two threads. using var barrier = new Barrier(2); var parentTask = Task.Run(() => { @@ -345,8 +331,6 @@ public async Task Serialization_ParentAndChildDoNotInterleave() }); await Task.WhenAll(parentTask, childTask); - // Every start/end pair must match. If parent and child callbacks ever interleaved, a P-start - // would be followed by C-start (or vice versa) instead of its own P-end. lock (callLog) { callLog.Count.Should().BeGreaterThan(0); @@ -360,12 +344,6 @@ public async Task Serialization_ParentAndChildDoNotInterleave() } } - /// - /// Proves Orchestrate delivery runs without holding the lock. Two orchestrator instances - /// whose Emit callbacks write into each other's source cache, creating a cross-cache cycle. - /// Deadlocks if downstream delivery is held under the queue lock; passes when the queue is - /// drained before invoking Emit. - /// [Trait("Category", "ExplicitDeadlock")] [Fact] public async Task DeadlockProof_CrossFeedingSubscriptions() @@ -407,12 +385,6 @@ public async Task DeadlockProof_CrossFeedingSubscriptions() "cross-feeding Orchestrate subscriptions should not deadlock"); } - /// - /// Concurrent source/inner emissions during the orchestrator's per-drain emit must not be - /// lost: items delivered via the reentrant drain inside Emitter.OnNext settle into the - /// orchestrator's state and must be flushed before drain exits, otherwise downstream - /// observers miss them. - /// [Fact] public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstream() { @@ -422,7 +394,6 @@ public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstrea using var source = new SourceCache(x => x.Key); - // Per-source-item inner subject so each producer task has a stable inner stream to push into. var innerSubjects = new Dictionary>(); for (var i = 1; i <= producerCount; i++) { @@ -433,7 +404,6 @@ public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstrea var observer = new TestObserver(); using var sub = observable.Subscribe(observer); - // Add a source item per producer to subscribe each inner subject. for (var i = 1; i <= producerCount; i++) { source.AddOrUpdate(new TestItem(i, "init")); @@ -441,7 +411,6 @@ public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstrea var orchestrator = getOrchestrator(); - // Reset child-call tracking captured during init so we count only the burst below. lock (orchestrator.ChildCalls) { orchestrator.ChildCalls.Clear(); @@ -459,8 +428,6 @@ public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstrea await Task.WhenAll(producers); - // Spin until every emission has reached OnInner. Deterministic upper bound guards against - // a real deadlock; under healthy conditions this returns quickly. SpinWait.SpinUntil( () => { lock (orchestrator.ChildCalls) return orchestrator.ChildCalls.Count >= totalEmissions; }, TimeSpan.FromSeconds(5)) @@ -478,11 +445,6 @@ public async Task ReentrantDrain_ConcurrentInnerEmissions_AllItemsReachDownstrea } } - /// - /// The lambda overload of Orchestrate must build a fresh orchestrator per subscription; - /// the orchestrator holds mutable per-subscription state and reuse across subscribers corrupts - /// the first subscriber's context. - /// [Fact] public void LambdaOverload_MultipleSubscriptions_DoNotShareOrchestrator() { @@ -491,12 +453,9 @@ public void LambdaOverload_MultipleSubscriptions_DoNotShareOrchestrator() var emitCalls = 0; var contexts = new List(); - // Build a chain that captures whichever context each orchestrator received. Two subscribers - // should each see their own context instance. var observable = source.Connect().Orchestrate( onSourceChangeSet: (changes, context) => { - // Hash code of the context instance proves each subscription has its own. lock (contexts) { contexts.Add(System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(context)); @@ -517,11 +476,6 @@ public void LambdaOverload_MultipleSubscriptions_DoNotShareOrchestrator() } } - // ═══════════════════════════════════════════════════════════════ - // 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; @@ -539,9 +493,6 @@ public void OnError(Exception error) { } public void OnCompleted() { } } - /// - /// Minimal ICacheOrchestrator implementation that mirrors the legacy CPS-subclass shape. - /// private sealed class TestOrchestrator( ICacheOrchestratorContext context, IObserver> emitter, @@ -597,7 +548,6 @@ public void OnDrainComplete(bool isFinal, bool wasReentrant) } } - /// Observer that records emissions, completion, and errors. private sealed class TestObserver : IObserver> { public int EmitCount; diff --git a/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs b/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs index 3abfea4e..808fed28 100644 --- a/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs +++ b/src/DynamicData.Tests/Internal/SharedDeliveryQueueFixture.cs @@ -175,15 +175,6 @@ public async Task ConcurrentMultiSourceDelivery() } } - /// - /// Regression test for the C1 fix: when _onDrainComplete enqueues an item onto a - /// sub-queue (modeling an orchestrator emitting accumulated state), the reentrant drain - /// inside ExitLockAndDrain processes the item but leaves _activeBits empty. - /// Without the fix, the outer DrainAll exits without re-firing _onDrainComplete; - /// any state the orchestrator accumulates during the reentrant drain is then stranded until - /// the next unrelated drain. The fix flags drain reentrancy and loops _onDrainComplete - /// when the flag is set, even when _activeBits shows nothing pending. - /// [Fact] public void OnDrainComplete_RefiresAfterReentrantDrainTriggeredByCallback() { @@ -194,10 +185,6 @@ public void OnDrainComplete_RefiresAfterReentrantDrainTriggeredByCallback() SharedDeliveryQueue queue = null!; queue = new SharedDeliveryQueue(onDrainComplete: _ => { - // Emit one item on the first callback invocation only. The enqueue happens while we - // are inside DrainAll on the drain thread, so ExitLockAndDrain takes the reentrant - // path and consumes the item without setting _activeBits. The outer DrainAll must - // still re-fire OnDrainComplete because of the reentrancy flag. if (Interlocked.Increment(ref callCount) == 1) { using var scope = sub!.AcquireLock(); @@ -208,7 +195,6 @@ public void OnDrainComplete_RefiresAfterReentrantDrainTriggeredByCallback() var observer = new TestObserver(delivered.Add); sub = queue.CreateQueue(observer); - // Prime the drain with an initial item so DrainAll fires onDrainComplete at least once. using (var scope = sub.AcquireLock()) { scope.EnqueueNext(1); diff --git a/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs b/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs index 483f50ac..e09efe78 100644 --- a/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs +++ b/src/DynamicData.Tests/Internal/TransformManyAsyncOrchestratorFixture.cs @@ -16,12 +16,6 @@ namespace DynamicData.Tests.Internal; -/// -/// In-isolation tests for . -/// Verifies that async transformer results are tracked per key, that removals untrack and clean the -/// merge tracker, that drain end flushes accumulated changes, and that a transformer exception is -/// routed through the user-provided error handler. -/// public sealed class TransformManyAsyncOrchestratorFixture { private sealed record Item(int Id); @@ -102,8 +96,6 @@ public async Task TransformerThrows_RoutesThroughErrorHandler() orchestrator.OnSourceChangeSet(new ChangeSet { new(ChangeReason.Add, 1, new Item(1)) }); - // Subscribe and await the deferred inner to its terminal. The async lambda runs synchronously - // (sync throw -> catch -> handler -> return Empty), so the inner completes deterministically. var trackedObs = context.Tracked[1]; await trackedObs.DefaultIfEmpty().LastOrDefaultAsync(); diff --git a/src/DynamicData.Tests/Utilities/CollectingObserver.cs b/src/DynamicData.Tests/Utilities/CollectingObserver.cs index 92d8f85f..cd4167cc 100644 --- a/src/DynamicData.Tests/Utilities/CollectingObserver.cs +++ b/src/DynamicData.Tests/Utilities/CollectingObserver.cs @@ -8,9 +8,7 @@ namespace DynamicData.Tests.Utilities; /// -/// Minimal that records every OnNext value and the terminal state. -/// Intended for in-isolation orchestrator tests where the orchestrator's emitter side-effects -/// need to be captured without involving an Rx pipeline. +/// that records every OnNext value and the terminal state. /// internal sealed class CollectingObserver : IObserver { diff --git a/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs b/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs index 80b474dc..7e67ca5a 100644 --- a/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs +++ b/src/DynamicData.Tests/Utilities/FakeOrchestratorContext.cs @@ -10,11 +10,9 @@ namespace DynamicData.Tests.Utilities; /// -/// In-memory implementation of for testing -/// orchestrators in isolation, without spinning up the full SharedDeliveryQueue + CacheOrchestration -/// runtime. Records every and call so tests can assert on -/// the orchestrator's subscription lifecycle behavior, and exposes the currently tracked observables -/// via . +/// 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. @@ -24,13 +22,10 @@ internal sealed class FakeOrchestratorContext : ICacheOrchestrator { private readonly Dictionary> _tracked = []; - /// Snapshot of every Track call made on this context, in order of receipt. public List<(TKey Key, IObservable Observable)> TrackCalls { get; } = []; - /// Snapshot of every Untrack call made on this context, in order of receipt. public List UntrackCalls { get; } = []; - /// Currently registered observables, keyed by their source key. Reflects Track/Untrack history. public IReadOnlyDictionary> Tracked => _tracked; public void Track(TKey key, IObservable observable) @@ -45,10 +40,5 @@ public void Untrack(TKey key) _tracked.Remove(key); } - /// - /// Returns unchanged. The fake does not provide queue-style - /// serialization; tests that need ordering guarantees should drive the orchestrator - /// synchronously from a single thread. - /// public IObservable Serialize(IObservable observable) => observable; } diff --git a/src/DynamicData/Cache/Internal/CacheOrchestration.cs b/src/DynamicData/Cache/Internal/CacheOrchestration.cs index abaa0478..557ba7a5 100644 --- a/src/DynamicData/Cache/Internal/CacheOrchestration.cs +++ b/src/DynamicData/Cache/Internal/CacheOrchestration.cs @@ -11,9 +11,8 @@ namespace DynamicData.Cache.Internal; /// /// Drives an against a source -/// changeset. A fresh per-subscription is constructed by -/// on each subscribe, with the orchestrator built by the supplied -/// , so all per-subscription state is naturally isolated. +/// changeset. builds a fresh per-subscription +/// from the supplied . /// /// Type of items in the source changeset. /// Type of the source changeset key. diff --git a/src/DynamicData/Cache/Internal/CacheOrchestratorBase.cs b/src/DynamicData/Cache/Internal/CacheOrchestratorBase.cs index d6088c3b..4454181b 100644 --- a/src/DynamicData/Cache/Internal/CacheOrchestratorBase.cs +++ b/src/DynamicData/Cache/Internal/CacheOrchestratorBase.cs @@ -7,10 +7,6 @@ namespace DynamicData.Cache.Internal; /// /// Optional base for implementations /// that prefer per- virtual hooks over decoding the changeset themselves. -/// The base implementation of walks the changeset and dispatches each -/// change to the corresponding protected virtual method. Captures the -/// and downstream emitter in -/// and properties accessible to derived classes. /// /// Type of items in the source changeset. /// Type of the source changeset key. diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs index e2843f66..c7c4da2b 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.Orchestrate.cs @@ -17,23 +17,23 @@ namespace DynamicData.Cache.Internal; /// Operator shapeUse /// /// Single value type (TResult), needs explicit orchestrator class -/// + custom (or subclass ). Used by AutoRefresh, TransformManyAsync. +/// + custom (or subclass ). /// /// /// Stateless, simple per-reason logic, value output -/// lambda overload. Used by MergeMany, MergeManyItems. +/// lambda overload. /// /// /// Output is a cache changeset, you mutate a ChangeAwareCache per source/inner event -/// . Used by FilterOnObservable, TransformOnObservable. +/// . /// /// /// Output is a merged cache changeset (inner observables themselves emit cache changesets) -/// (cache overload). Used by MergeManyChangeSets. +/// (cache overload). /// /// /// Output is a merged list changeset (inner observables emit list changesets) -/// (list overload). Used by MergeManyListChangeSets. +/// (list overload). /// /// /// diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs index 8d5b5e84..3963d7fe 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateChangeSets.cs @@ -13,7 +13,7 @@ internal static partial class IntObservableCacheEx /// . 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. Used by FilterOnObservable, TransformOnObservable. + /// output. /// /// Type of items in the source changeset. /// Type of the source changeset key (also the output changeset key). diff --git a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs index e04d53a1..08a68e8e 100644 --- a/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs +++ b/src/DynamicData/Cache/Internal/IntObservableCacheEx.OrchestrateManyChangeSets.cs @@ -11,7 +11,7 @@ internal static partial class IntObservableCacheEx { /// /// Cache-shape overload. Per-source-key inner observables that emit cache changesets, merged - /// into a single output cache changeset. Used by MergeManyChangeSets (cache) and TransformManyAsync. + /// into a single output cache changeset. /// /// Type of items in the source changeset. /// Type of the source changeset key. @@ -38,7 +38,7 @@ public static IObservable> OrchestrateManyChangeSets /// /// List-shape overload. Per-source-key inner observables that emit list changesets, merged - /// into a single output list changeset. Used by MergeManyListChangeSets. + /// into a single output list changeset. /// /// Type of items in the source changeset. /// Type of the source changeset key. diff --git a/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs b/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs index 5de96a18..2b6eff2e 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.AutoRefreshOnObservable.cs @@ -59,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 terminate the output stream. Prior to v9 they were silently ignored; as of v9 they propagate, matching the change made to . + /// 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 490026e3..094ae562 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeMany.cs @@ -51,7 +51,6 @@ public static partial class ObservableCacheEx /// RefreshNo effect on subscriptions. The child observable continues unchanged. /// 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: Prior to v9, errors from child observables were silently swallowed and the offending child was unsubscribed while the merged stream continued. As of v9 child errors are forwarded, matching the semantics of standard Observable.Merge. /// Worth noting: The output is a plain , not a changeset stream. If you need merged changesets, use instead. /// /// or is null. diff --git a/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs index 1ab2ea37..f9d03624 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. /// /// diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs index 4c38c5a7..489cfa9c 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.WhenAnyPropertyChanged.cs @@ -48,7 +48,6 @@ public static partial class ObservableCacheEx /// RefreshNo effect on subscriptions. /// OnErrorErrors from any item's property subscription terminate the output stream. Source errors also terminate the stream. /// - /// Worth noting: Prior to v9, errors from individual property subscriptions were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs index 9cdba51c..c849ad74 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.WhenPropertyChanged.cs @@ -49,7 +49,6 @@ public static partial class ObservableCacheEx /// RefreshNo effect on subscriptions. The existing property subscription continues. /// OnErrorErrors from any item's property subscription terminate the output stream. Source errors also terminate the stream. /// - /// Worth noting: Prior to v9, per-item property subscription errors were silently ignored. As of v9 they propagate via the underlying . /// /// public static IObservable> WhenPropertyChanged(this IObservable> source, Expression> propertyAccessor, bool notifyOnInitialValue = true) diff --git a/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs b/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs index 0527b5e1..202b90d6 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.WhenValueChanged.cs @@ -49,7 +49,6 @@ public static partial class ObservableCacheEx /// RefreshNo effect on subscriptions. /// OnErrorErrors from any item's property subscription terminate the output stream. Source errors also terminate the stream. /// - /// Worth noting: Prior to v9, per-item errors were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/Internal/SharedDeliveryQueue.cs b/src/DynamicData/Internal/SharedDeliveryQueue.cs index 381e3035..e45a4735 100644 --- a/src/DynamicData/Internal/SharedDeliveryQueue.cs +++ b/src/DynamicData/Internal/SharedDeliveryQueue.cs @@ -160,8 +160,7 @@ internal void ExitLockAndDrain() if (_drainThreadId == currentThreadId) { ExitLock(); - // Flag the reentrancy so the outer DrainAll re-fires _onDrainComplete - // for orchestrators that accumulate state during this reentrant drain. + // Flag so the outer DrainAll re-fires _onDrainComplete after this reentrant drain. _drainReentered = true; DrainPending(); return; @@ -217,8 +216,7 @@ private void DrainAll() _onDrainComplete(wasReentrant); } - // Loop back if items are pending OR a reentrant drain ran during _onDrainComplete - // (which may have updated orchestrator state that needs another flush). + // Loop back if items are pending OR a reentrant drain ran during _onDrainComplete. EnterLock(); if ((_activeBits.HasAny() || _drainReentered) && !_isTerminated) diff --git a/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs b/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs index 5dca78ac..1c108c2e 100644 --- a/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs +++ b/src/DynamicData/List/ObservableListEx.AutoRefreshOnObservable.cs @@ -49,7 +49,6 @@ public static partial class ObservableListEx /// 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. /// - /// Worth noting: Prior to v9, per-item re-evaluator errors were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs b/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs index d40d2635..5dc6f380 100644 --- a/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs +++ b/src/DynamicData/List/ObservableListEx.FilterOnObservable.cs @@ -52,7 +52,6 @@ public static partial class ObservableListEx /// 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. /// - /// Worth noting: Prior to v9, errors from per-item filter observables were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.MergeMany.cs b/src/DynamicData/List/ObservableListEx.MergeMany.cs index 10f6e902..73de2fd8 100644 --- a/src/DynamicData/List/ObservableListEx.MergeMany.cs +++ b/src/DynamicData/List/ObservableListEx.MergeMany.cs @@ -43,7 +43,6 @@ public static partial class ObservableListEx /// 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. /// - /// Worth noting: Prior to v9, errors from per-item observables were silently swallowed and the offending item was unsubscribed while the merged stream continued. As of v9 child errors propagate, matching the semantics of standard Observable.Merge. /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs b/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs index 1aa43c99..a6529e3f 100644 --- a/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs +++ b/src/DynamicData/List/ObservableListEx.WhenAnyPropertyChanged.cs @@ -35,7 +35,6 @@ public static partial class ObservableListEx /// is . /// /// Implemented via . Subscriptions are managed per item: created on add, disposed on remove. - /// Worth noting: Prior to v9, errors from individual property subscriptions were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs b/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs index 52e19e3f..4a91bd03 100644 --- a/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs +++ b/src/DynamicData/List/ObservableListEx.WhenPropertyChanged.cs @@ -37,7 +37,6 @@ public static partial class ObservableListEx /// or is . /// /// Implemented via . - /// Worth noting: Prior to v9, per-item property subscription errors were silently ignored. As of v9 they propagate via the underlying . /// /// /// diff --git a/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs b/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs index 0e6b0a97..0328218f 100644 --- a/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs +++ b/src/DynamicData/List/ObservableListEx.WhenValueChanged.cs @@ -35,9 +35,6 @@ public static partial class ObservableListEx /// When (default), the current value is emitted immediately upon subscribing to each item. /// An observable emitting the property value whenever it changes on any tracked item. /// or is . - /// - /// Worth noting: Prior to v9, per-item errors were silently ignored. As of v9 they propagate via the underlying . - /// /// /// /// From bf0ed30544bdebd06cfbe521450e6861890423a8 Mon Sep 17 00:00:00 2001 From: dwcullop Date: Sun, 14 Jun 2026 18:25:08 -0700 Subject: [PATCH 30/30] Delete redundant cache MergeManyListChangeSets wrapper The cache-side MergeManyListChangeSets class was a one-line wrapper around OrchestrateManyChangeSets. Inline the call at the public API site and remove the file. The list-side MergeManyListChangeSets remains; it serves a list-source pipeline and has no orchestrator equivalent. --- .../Cache/Internal/MergeManyListChangeSets.cs | 22 ------------------- .../ObservableCacheEx.MergeManyChangeSets.cs | 2 +- 2 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs diff --git a/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs b/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs deleted file mode 100644 index 3d17a0d7..00000000 --- a/src/DynamicData/Cache/Internal/MergeManyListChangeSets.cs +++ /dev/null @@ -1,22 +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 DynamicData.Internal; - -namespace DynamicData.Cache.Internal; - -/// -/// Operator that is similar 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() => - source.OrchestrateManyChangeSets(selector, equalityComparer); -} diff --git a/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs index f9d03624..659c94f5 100644 --- a/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs +++ b/src/DynamicData/Cache/ObservableCacheEx.MergeManyChangeSets.cs @@ -426,7 +426,7 @@ public static IObservable> MergeManyChangeSets(source, observableSelector, equalityComparer).Run(); + return source.OrchestrateManyChangeSets(observableSelector, equalityComparer); } ///