From e3f9d2502daf463d6005e4183eb8f3418f2eafef Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Wed, 5 Aug 2026 22:10:07 -0700 Subject: [PATCH 1/2] Move change batching out of the delivery queue SharedDeliveryQueue took an optional callback that fired once per drain cycle, and CacheParentSubscription used it as its only emit point. That put a batching policy inside a class whose job is serialization, and it batched more than it should: DrainPending drains whatever is queued, so work another thread enqueued mid-drain landed in the same emission. CacheParentSubscription now tracks its own delivery frame. Each notification increments a depth counter, and the accumulated changes are emitted when it returns to zero. A child that emits synchronously during parent processing is delivered inline by the queue's reentrant path, so it nests inside the parent's frame rather than emitting separately. One upstream notification plus everything it triggers synchronously still produces one downstream changeset, which the six operators built on this class already relied on. What changes is that a second thread's work is no longer folded into the same emission. It gets its own frame. No lock is needed around the depth counter. The queue has already serialized delivery, so only one thread is ever inside these methods, and the queue's lock provides the barrier between drains on different threads. With that moved, the callback has no consumers, so the field, the overload that took it and the invoke site are all gone. SharedDeliveryQueue now only serializes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9582bb33-26d3-4aa5-8dd7-57dc55304680 --- .../Internal/CacheParentSubscription.cs | 80 +++++++++++++++++-- .../Internal/SharedDeliveryQueue.cs | 14 ---- 2 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/DynamicData/Internal/CacheParentSubscription.cs b/src/DynamicData/Internal/CacheParentSubscription.cs index 3a33143df..2960cd974 100644 --- a/src/DynamicData/Internal/CacheParentSubscription.cs +++ b/src/DynamicData/Internal/CacheParentSubscription.cs @@ -13,7 +13,8 @@ namespace DynamicData.Internal; /// 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. +/// Accumulated changes are emitted once per delivery frame, where a frame is one +/// notification plus anything delivered synchronously beneath it on the same thread. /// /// Type of the Parent ChangeSet. /// Type for the Parent ChangeSet Key. @@ -29,6 +30,7 @@ internal abstract class CacheParentSubscription _observer; private int _subscriptionCounter = 1; // Starts at 1 for the parent subscription + private int _frameDepth; private bool _isCompleted; private bool _hasTerminated; private bool _disposedValue; @@ -40,7 +42,7 @@ internal abstract class CacheParentSubscription observer) { _observer = observer; - _queue = new SharedDeliveryQueue(onDrainComplete: OnDrainComplete); + _queue = new SharedDeliveryQueue(); } /// @@ -76,9 +78,9 @@ protected void AddChildSubscription(IObservable observable, TKey parentK disposableContainer.Disposable = observable .Finally(CheckCompleted) .SubscribeSafe( - onNext: val => ChildOnNext(val, parentKey), + onNext: val => DeliverChild(val, parentKey), onError: TerminalError, - onCompleted: () => RemoveChildSubscription(parentKey)); + onCompleted: () => CompleteChild(parentKey)); } protected void RemoveChildSubscription(TKey parentKey) => _childSubscriptions.Remove(parentKey); @@ -88,9 +90,9 @@ protected void CreateParentSubscription(IObservable> s source .SynchronizeSafe(_queue) .SubscribeSafe( - onNext: ParentOnNext, + onNext: DeliverParent, onError: TerminalError, - onCompleted: CheckCompleted); + onCompleted: CompleteParent); protected virtual void Dispose(bool disposing) { @@ -116,8 +118,72 @@ protected virtual void Dispose(bool disposing) protected IObservable MakeChildObservable(IObservable observable) => observable.SynchronizeSafe(_queue); - private void OnDrainComplete() + private void DeliverParent(IChangeSet changes) { + ++_frameDepth; + try + { + ParentOnNext(changes); + } + finally + { + EndFrame(); + } + } + + private void DeliverChild(TChild child, TKey parentKey) + { + ++_frameDepth; + try + { + ChildOnNext(child, parentKey); + } + finally + { + EndFrame(); + } + } + + private void CompleteParent() + { + ++_frameDepth; + try + { + CheckCompleted(); + } + finally + { + EndFrame(); + } + } + + private void CompleteChild(TKey parentKey) + { + ++_frameDepth; + try + { + RemoveChildSubscription(parentKey); + } + finally + { + EndFrame(); + } + } + + /// + /// Closes the current delivery frame. Deliveries nested beneath this one, which the queue + /// runs inline on the same thread, close their own frame first and leave the emit to the + /// outermost, so one upstream notification and everything it triggers synchronously produce + /// a single downstream changeset. No lock is needed around the depth because the queue has + /// already serialized delivery. + /// + private void EndFrame() + { + if (--_frameDepth != 0) + { + return; + } + EmitChanges(_observer); if (Volatile.Read(ref _isCompleted) && !_hasTerminated) diff --git a/src/DynamicData/Internal/SharedDeliveryQueue.cs b/src/DynamicData/Internal/SharedDeliveryQueue.cs index 3ca1f2b2c..cdcd762f2 100644 --- a/src/DynamicData/Internal/SharedDeliveryQueue.cs +++ b/src/DynamicData/Internal/SharedDeliveryQueue.cs @@ -29,8 +29,6 @@ internal sealed class SharedDeliveryQueue : IDisposable /// private readonly Queue _order = new(); - private readonly Action? _onDrainComplete; - #if NET9_0_OR_GREATER private readonly Lock _gate; #else @@ -42,22 +40,12 @@ internal sealed class SharedDeliveryQueue : IDisposable /// Initializes a new instance of the class with its own internal lock. public SharedDeliveryQueue() - : this(onDrainComplete: null) - { - } - - /// - /// 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. - /// - public SharedDeliveryQueue(Action? onDrainComplete) { #if NET9_0_OR_GREATER _gate = new Lock(); #else _gate = new object(); #endif - _onDrainComplete = onDrainComplete; } #if NET9_0_OR_GREATER @@ -167,8 +155,6 @@ private void DrainAll() return; } - _onDrainComplete?.Invoke(); - // Atomically re-check for work and release ownership if there is none. Checking // and releasing in separate lock scopes would let a producer enqueue in between, // see that a drain is in progress, and rely on us to deliver an item we never saw. From aa77d4efc7df77fdfd5b190811090a40e1a5fa94 Mon Sep 17 00:00:00 2001 From: "Darrin W. Cullop" Date: Mon, 24 Aug 2026 14:57:28 -0700 Subject: [PATCH 2/2] Scope delivery frames with a disposable FrameTracker Pairs the frame counter with an explicit BeginFrame() that returns a FrameTracker, so each delivery scopes its frame with a using instead of a hand-rolled try/finally around ++_frameDepth. Same semantics, less to keep in sync. --- .../Internal/CacheParentSubscription.cs | 74 +++++++++---------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/src/DynamicData/Internal/CacheParentSubscription.cs b/src/DynamicData/Internal/CacheParentSubscription.cs index 2960cd974..e817c9fb3 100644 --- a/src/DynamicData/Internal/CacheParentSubscription.cs +++ b/src/DynamicData/Internal/CacheParentSubscription.cs @@ -120,62 +120,45 @@ protected IObservable MakeChildObservable(IObservable observable) => private void DeliverParent(IChangeSet changes) { - ++_frameDepth; - try - { - ParentOnNext(changes); - } - finally - { - EndFrame(); - } + using var frame = BeginFrame(); + ParentOnNext(changes); } private void DeliverChild(TChild child, TKey parentKey) { - ++_frameDepth; - try - { - ChildOnNext(child, parentKey); - } - finally - { - EndFrame(); - } + using var frame = BeginFrame(); + ChildOnNext(child, parentKey); } private void CompleteParent() { - ++_frameDepth; - try - { - CheckCompleted(); - } - finally - { - EndFrame(); - } + using var frame = BeginFrame(); + CheckCompleted(); } private void CompleteChild(TKey parentKey) + { + using var frame = BeginFrame(); + RemoveChildSubscription(parentKey); + } + + /// + /// Opens a delivery frame that stays open until the returned is disposed. + /// Deliveries nested beneath this one, which the queue runs inline on the same thread, open and close + /// their own frame and leave the emit to the outermost, so one upstream notification and everything it + /// triggers synchronously produce a single downstream changeset. No lock is needed around the depth + /// because the queue has already serialized delivery. + /// + /// A tracker that closes the frame when disposed. + private FrameTracker BeginFrame() { ++_frameDepth; - try - { - RemoveChildSubscription(parentKey); - } - finally - { - EndFrame(); - } + return new FrameTracker(this); } /// - /// Closes the current delivery frame. Deliveries nested beneath this one, which the queue - /// runs inline on the same thread, close their own frame first and leave the emit to the - /// outermost, so one upstream notification and everything it triggers synchronously produce - /// a single downstream changeset. No lock is needed around the depth because the queue has - /// already serialized delivery. + /// Closes the current delivery frame, emitting the accumulated changes only when the outermost + /// frame closes. /// private void EndFrame() { @@ -208,4 +191,15 @@ private void CheckCompleted() Debug.Assert(_subscriptionCounter >= 0, "Should never be negative"); } + + /// + /// Closes the delivery frame opened by when disposed, so a frame can be + /// scoped with instead of pairing the calls by hand. + /// + /// The subscription whose frame is being tracked. + private readonly struct FrameTracker(CacheParentSubscription owner) : IDisposable + { + /// + public void Dispose() => owner.EndFrame(); + } }