Skip to content

perf(outbox): drop the sort and the copy from the in-memory outstanding count - #4583

Merged
iancooper merged 6 commits into
BrighterCommand:masterfrom
guilhermeappel:perf/4554-inmemory-outstanding-count
Oct 11, 2026
Merged

iancooper merged 6 commits into
BrighterCommand:masterfrom
guilhermeappel:perf/4554-inmemory-outstanding-count

Conversation

@guilhermeappel

@guilhermeappel guilhermeappel commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Description

This is the last of the three PRs I described on issue #4554, after PR #4564 and PR #4569. Those two cut how often the outstanding count runs. This one makes the count itself cheaper for the InMemoryOutbox.

GetOutstandingMessageCount copied every entry with Requests.Values, which holds all of the dictionary's locks while it copies, then sorted the copy by time stamp, and only then filtered it and stopped at maxCount. The order doesn't change how many entries match, so I removed the sort. The count now walks the ConcurrentDictionary directly, as RemoveExpiredMessages and Compact already do, so it copies nothing and the walk takes no lock. It returns the same number as before.

Walking the dictionary isn't a point-in-time view, so an entry added, dispatched or deleted during the walk may or may not be counted. The copy wasn't one either, because MarkDispatched sets TimeFlushed on the entry in place, and the count is already out of date by the time the mediator compares it with the limit. The cleanup worker only removes dispatched entries, which the count skips anyway.

Related Issues

Fixes #4554. The static semaphore and the missing timeout and cancellation on the count aren't part of this PR. Now that the three PRs are up, I'll open the separate issue I mentioned on PR #4552 for them, and my question on issue #4554 about whether the semaphore is intentional can move there.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the Contributing Guide
  • I have checked the documentation for relevant guidance
  • I have added/updated XML documentation for any public API changes (no public API change)
  • I have added/updated tests as appropriate
  • My changes follow the existing code style and conventions

Additional Notes

  • The tests come first and pass on master. They pin what the count returns, and the test commit lists the mutations of GetOutstandingMessageCount I checked them against. Then the sort goes, then the copy, and the release note comes last, as one more paragraph in the issue Each Post sorts the whole in-memory outbox to count outstanding messages, even with no limit set #4554 entry, which I retitled to match.

  • One call of GetOutstandingMessageCount with a maxCount of 101 (what the mediator passes with a limit of 100), Release build on net10.0 on my machine. The times move between runs, the allocations don't:

    Entries KB per call before / after Time per call before / after
    2,000, all dispatched 70.8 / 0.28 148 to 225 µs / 90 to 105 µs
    20,000, all dispatched 704 / 0.28 4.0 to 4.5 ms / 0.69 to 0.71 ms
    20,000, 1 in 10 outstanding 704 / 0.28 3.8 to 4.1 ms / 20 to 31 µs

    At 20,000 entries the old count put arrays on the large object heap: 1,500 calls caused 35 gen2 collections before and none after. Most of the cost was the sort. Compared with the commit that only drops the sort, dropping the copy as well makes a count that has to visit every entry slower: in an earlier run, up to about twice as slow with 2,000 entries and about 5 to 40% slower with 20,000. In exchange the count no longer allocates more as the outbox grows (with only the sort gone, the copy was still 157 KB per call at 20,000 entries, with 5 gen2 collections per 1,500 calls), and writes to the outbox no longer wait for it.

  • Per Post the gain is smaller now that PR fix(outbox): stop the outstanding count checks from piling up #4569 runs the count far less often. With AddProducers, a limit of 100000, the default interval and one poster, a Post allocated about 21 to 24 KB before and 17 to 18 KB after. With 16 posters at once, or with no limit, there is no real difference.

  • The InMemory suite passes on net10.0 and net9.0 (163 passed on each, 167 once merged with the current master), and so does the Core suite (1707 passed, 7 skipped on each).

  • Please merge with a merge commit rather than squash, as CONTRIBUTING asks.

Pin what InMemoryOutbox.GetOutstandingMessageCount returns before
changing how it is computed: only undispatched entries at least one
interval old are counted, the count stops at maxCount, and a zero
maxCount counts nothing.

The tests pass on the current code. Each was seen to fail under a
temporary mutation of GetOutstandingMessageCount, never committed:

- counting dispatched entries too: the interval test fails
- comparing WriteTime with < instead of <=: the interval test fails
- comparing WriteTime with > or >= instead of <=: the interval and
  max count tests fail
- dropping Take(maxCount): the max count and zero max count tests fail
- ageing entries by Message.Header.TimeStamp instead of WriteTime: the
  interval and max count tests fail
- sorting by time stamp and taking maxCount entries before the filter:
  the max count test fails, as the dispatched entries carry the oldest
  time stamps
- a loop that only checks the cap after counting an entry: the zero
  max count test fails

Part of issue BrighterCommand#4554
The number of matching entries, capped at maxCount, does not depend on
the order they are visited in, so the sort by time stamp was wasted
work. On every count the OrderBy buffered every entry again, with an
array of their time stamps and an index map, and sorted them before
the filter could run. Remove the OrderBy.

Part of issue BrighterCommand#4554
Requests.Values copies every entry into a new list while it holds all
of the dictionary locks, so each count allocated in proportion to the
size of the outbox and held up writes to it while it copied. Enumerate
the ConcurrentDictionary directly, as RemoveExpiredMessages and Compact
already do, so nothing is copied and the walk takes no lock.

When the count has to visit every entry, walking the dictionary takes
longer than copying it and walking the copy did, but the count no
longer allocates more as the outbox grows.

The walk is not a point-in-time view, but the copy was not one either:
MarkDispatched sets TimeFlushed on the entry in place, outside those
locks. The cleanup worker only removes dispatched entries, which the
count skips, and the mediator only compares the count with the limit
later, so it is out of date by then anyway.

Part of issue BrighterCommand#4554
The issue BrighterCommand#4554 entry covers how often the outstanding count runs, but
not the count in the InMemoryOutbox, which no longer copies and sorts
every entry. Add a paragraph for it and retitle the entry to match.

Part of issue BrighterCommand#4554

@iancooper iancooper left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@iancooper

Copy link
Copy Markdown
Member

Thanks @guilhermeappel

@iancooper
iancooper merged commit e3dd4d9 into BrighterCommand:master Oct 11, 2026
32 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Each Post sorts the whole in-memory outbox to count outstanding messages, even with no limit set

2 participants