Make the delivery queue deliver in order, and only serialize - #1162
Draft
dwcullop wants to merge 2 commits into
Draft
Make the delivery queue deliver in order, and only serialize#1162dwcullop wants to merge 2 commits into
dwcullop wants to merge 2 commits into
Conversation
SharedDeliveryQueue partitions pending notifications into a typed sub-queue per source, and the drain loop picked the next one with Bitset.FindHighest(). Selection was therefore by sub-queue index rather than by arrival, so notifications from two sources could be delivered in an order they were not received in. With receipt order A1, A2, Bx, delivery came out A1, Bx, A2. The sub-queues are still typed, so the payloads are still held as structs and queuing one still costs no allocation. What changes is that a second queue now records which source each pending notification came from, in arrival order, and the drain loop follows that instead of scanning a bitset. The entries are sub-queue references that already exist, so recording the order does not allocate either. Highest-index-first was there so a child sub-queue drained before a parent's delivery could dispose it. That is no longer needed. A disposed sub-queue drops its pending notifications, and its leftover order entries are skipped when the drain reaches them, which is the same outcome by a shorter route. This also removes Bitset, which had no other caller, along with the sub-queue index bookkeeping and the compaction pass that existed to keep the bitset dense. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9582bb33-26d3-4aa5-8dd7-57dc55304680
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 instead. 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 and does not emit separately. One upstream notification plus everything it triggers synchronously still produces one downstream changeset, which is what the 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, which is the behaviour the batching was supposed to have. 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two things, both about
SharedDeliveryQueuedoing one job properly.1. It does not deliver in the order it received
Jake spotted this while looking at the DQ design and he is right, though the mechanism turned out a little different from the first diagnosis.
Pending notifications are partitioned into a typed sub-queue per source, and
DrainPendingpicked the next sub-queue withBitset.FindHighest(). That selects by sub-queue index, not by arrival, so two sources come out reordered. With receipt order A1, A2, Bx, delivery is A1, Bx, A2.This is reachable through the operators, not just by driving the queue directly.
SynchronizeSafe(queue)creates a sub-queue per subscription, so inPagethe page requests and the data changes land in different sub-queues and both mutate the samePaginator.A second queue now records which source each pending notification came from, in arrival order, and the drain loop follows that instead of scanning a bitset.
The sub-queues stay typed, which matters. The obvious alternative is one queue of type-erased notifications, but
Notification<T>is a readonly struct in aQueue<Notification<T>>, so queuing one currently allocates nothing, and type-erasing would add an allocation per notification. Keeping payloads where they are and recording only the order avoids that: the order entries are sub-queue references that already exist.Highest-index-first was deliberate. The comment on
DrainPendingexplains it: newer sub-queues are children of older ones, and a child had to drain before a parent's delivery could dispose it. That requirement disappears with arrival ordering. A disposed sub-queue drops its pending notifications, and any order entries it left behind are skipped when the drain reaches them.Bitsethad no other caller, so it goes, along with the sub-queue index bookkeeping and the compaction pass that kept the bitset dense.2. Batching does not belong in it
The queue also took an optional callback that fired once per drain cycle.
CacheParentSubscriptionused it as its only emit point, which put a batching policy inside a class whose job is serialization. It also batched more than intended:DrainPendingdrains whatever is queued, so work another thread enqueued mid-drain landed in the same emission.CacheParentSubscriptionnow 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.I checked what happens without that batching, out of curiosity about whether it was load-bearing: 34 tests fail, and the failures are all changeset granularity.
ClearingParentEmitsSingleChangeSetproduces 15 where it expects 2.OrderOfChangesIsPreservedproduces 11 where it expects 2, and that one matters beyond the count, because a singleEditdoing aClear()then anAddOrUpdate()should reach a consumer as one changeset. Split up, the collection is observably emptied, which is a state that never existed upstream.What does change 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 those methods, and the queue's lock provides the barrier between drains on different threads.
With that moved out, the callback has no consumers left, so the field, the overload that took it and the invoke site are all gone.
SharedDeliveryQueuenow only serializes.Tests
Three added to
SharedDeliveryQueueFixture. Two fail on the current implementation, which is the point:The third pins the disposal behaviour the old highest-index-first ordering was protecting, so it does not quietly regress.
Full solution builds on all seven target frameworks. I ran the suite four times end to end for each half of this and again for the two combined, plus the deadlock and delivery-queue fixtures twelve times with and without, since a single pass does not say much about concurrency coverage.
Independent of #1097, which removes gate-holding Rx combinators at the call sites and does not touch drain ordering. Both of these are in
maintoday either way.