Skip to content

Move change batching out of the delivery queue - #1163

Draft
dwcullop wants to merge 1 commit into
reactivemarbles:mainfrom
dwcullop:fix/delivery-queue-batching
Draft

Move change batching out of the delivery queue#1163
dwcullop wants to merge 1 commit into
reactivemarbles:mainfrom
dwcullop:fix/delivery-queue-batching

Conversation

@dwcullop

@dwcullop dwcullop commented Aug 6, 2026

Copy link
Copy Markdown
Member

SharedDeliveryQueue takes a callback that fires once per drain cycle, and CacheParentSubscription uses it as its only emit point. That is a batching policy living inside a class whose job is serialization. Raised on #1114.

It also batches more than intended. DrainPending drains whatever is queued, so work another thread enqueued mid-drain lands in the same emission.

Change

CacheParentSubscription tracks its own delivery frame:

private void EndFrame()
{
    if (--_frameDepth != 0)
    {
        return;
    }

    EmitChanges(_observer);
    ...
}

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 instead of emitting separately. One upstream notification plus everything it triggers synchronously still produces one downstream changeset.

The callback then has no consumers, so the field, the overload taking it, and the invoke site all go. SharedDeliveryQueue only serializes now.

No lock around the depth counter. The queue has already serialized delivery, so only one thread is ever inside those methods, and the queue's lock is the barrier between drains.

The batching is load-bearing

EmitChanges is abstract and that callback was its only caller, so the six operators built on this class stop emitting entirely without a replacement.

Emitting per notification instead fails 34 tests, all on changeset granularity. ClearingParentEmitsSingleChangeSet gives 15 where it expects 2. OrderOfChangesIsPreserved gives 11 where it expects 2, and that one is more than a count: a single Edit doing Clear() then AddOrUpdate() has to arrive as one changeset, or the collection is observably emptied, which is a state that never existed upstream.

What actually changes

A second thread's work is no longer folded into the same emission. It gets its own frame.

Related

#1162 fixes the queue delivering out of receipt order. Independent, but this change exposes that one: once emission is per frame, delivery order becomes changeset order. Same file, so whichever lands second needs a trivial merge.

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
private void OnDrainComplete()
private void DeliverParent(IChangeSet<TParent, TKey> changes)
{
++_frameDepth;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be crazy to say that this should be a StartFrame() or BeginFrame() method, just for the clarity that it pairs with EndFrame()?

Would it be even crazier to wrap up this logic in a private struct FrameTracker : IDisposable that does the tracking, and lets you just collapse it all to a using?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants