Repository navigation
fix: InMemoryBox cleanup loses compaction/expiry under contention; stop tests starving the thread pool - #4482
Conversation
…tion/expiry Root cause: InMemoryBox ran expiry and compaction as background tasks that Monitor.TryEnter a shared lock and silently skip if it is held. The compaction cooldown (set before the task runs) and the scan interval meant a skipped pass was never retried, so EntryLimit/TTL went unenforced. This made When_compacting_only_dispatched_messages_in_outbox flaky. Also fixes the same defect in InMemoryInbox (shared base class) and dropped expiry scans when compaction holds the lock, surfaced during confirmation. Fixes #4475 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ConcurrentStartGuardTests and the scheduler same-id concurrency test park 20 and 100 Task.Run work items on a Barrier, so none can proceed until the thread pool has grown that large. On a CI runner that starves every other pool work item in the process for 19-27s, delaying InMemoryBox background expiry/compaction past the waits in the outbox/inbox tests (seen in CI on #4482). Serialising them through a DisableParallelization collection stops them overlapping other tests. Refs #4475 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The blocking lock from the previous commit stopped cleanup work being lost, but under load every waiting request held a pool thread, and with a short ExpirationScanInterval and scans slower than it those blocked threads pile up and deepen the starvation that delays cleanup. A waiting compaction also acted on a stale entry count and could over-trim after expiry had already brought the box under its limit. Expiry and compaction are now recorded as requests and run by at most one worker per box, which loops until no requests remain; it decides to stop under the same lock requests are recorded under, so none can be missed. Compaction re-checks the limit when it runs and never trims below target. The worker runs on TaskScheduler.Default with no awaits or blocking waits, so it never touches a Reactor/Proactor pump's SynchronizationContext. Refs #4475 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@claude please review this PR, focusing only on the change it makes (not the wider codebase):
Background and the full diagnosis are in the PR description and in |
|
Claude finished @iancooper's task in 2m 55s —— View job Review of PR #4482: InMemoryBox single-worker cleanup and thread-pool starvation
Verdict: the design is sound. The handoff has no lost-wakeup race and cannot run two workers at once. I found no correctness bugs in the normal path. There are two low-severity points on the exception path, one gap in test coverage, and one inaccuracy in the PR description's "before fix" column. I did not build the code or run the tests; this review comes from reading the code. 1.
|
| Interleaving | Outcome |
|---|---|
Request recorded → worker takes it → caller sees running == true and returns |
Request is handled by the current pass ✅ |
Worker finds nothing and clears the flag → request recorded → caller sees false and starts a worker |
New worker handles it ✅ |
Request recorded → worker takes it, finishes and clears the flag → caller then sees false and starts a worker |
That worker finds nothing and exits. This costs one wasted pool dispatch but loses no work ✅ |
- Two workers at once: not possible. After
TryTakeCleanupRequestsreturnsfalse, the old worker only leavesRunCleanupWorkerand touches nothing else. So a new worker started at that point never overlaps real work. - Callers never wait on a scan. The lock is never held during
RemoveExpiredMessagesorCompact; it only covers a few field writes. - Pass order is right. Expiry runs before compaction in each pass.
InMemoryOutbox.AddcallsClearExpiredMessages()and thenEnforceCapacityLimit()(InMemoryOutbox.cs:142-143), so when both requests arrive together, expiry gets the first chance to make compaction unnecessary.
Exception path: ✅ the flag is reset, with two small gaps (low severity)
The catch resets _cleanupWorkerRunning and rethrows. The task is fire-and-forget, so the exception only surfaces through UnobservedTaskException, which is the same as before this PR.
- A request can be stranded. Suppose a request is recorded after the worker threw but before the
catchtakes the lock. That caller seesrunning == trueand returns. Thecatchthen clears the flag, and nothing picks the request up until the nextClearExpiredMessagesorEnforceCapacityLimitcall gets past its interval check. So the request is delayed by up to oneExpirationScanInterval, not lost for good. That is no worse than master, but it breaks the "no request is lost" claim in the comment atInMemoryBox.cs:157-160. - Compaction from the same pass is dropped. If
RemoveExpiredMessagesthrows, anycompactionRequestedtaken in the same pass has already been cleared from the slot and never runs. The cooldown then blocks a retry for another interval.
In practice both built-in implementations only call ConcurrentDictionary.TryRemove inside LINQ, so they are close to never throwing. That is why I rate this low. A small hardening option:
catch
{
bool pending;
lock (_cleanupStateLock)
{
_cleanupWorkerRunning = false;
pending = _expiryRequestedAt.HasValue || _compactionRequested.HasValue;
}
if (pending) EnsureCleanupWorker(); // bounded by the request rate, not a hot loop
throw;
}Another option is to wrap each operation in its own try/catch so that a failed expiry does not skip the compaction in the same pass. Either change is optional. If you skip both, consider softening the comment to "no request is lost unless a pass throws".
CompactIfStillOverLimit: ✅ correct for both boxes
- Re-check against
EntryLimit. It checkscount < upperSizeagainst the totalEntryCount, the same measure and threshold (>=) thatEnforceCapacityLimituses to trigger. So compaction is skipped exactly when a new trigger would not fire. - Outbox: only dispatched entries are eligible.
countincludes undispatched entries, andCompacttakes at most N dispatched entries, so the cap is an upper bound and never a promise. If the box is full of undispatched messages it stays over the limit and compaction removes whatever dispatched entries exist. That is the same as before the PR, and the right outcome: undispatched messages must not be dropped. - Inbox: every entry is eligible, so
count - newSizeis the exact number needed to reach the target. ✅ Math.Min(entriesRequested, count - newSize). If the box grew after the request was made, the smaller, older request wins. The box then ends slightly above the target, which is conservative and matches the original sizing. If the box shrank, the current value wins and the box never goes below the target. Both are fine. You could arguably dropentriesRequestedand always use the currentcount - newSize, but that is a matter of taste.- Old edge case, unchanged: if
CompactionPercentage > 1,count - newSizecan be negative, andTake(negative)simply removes nothing. This PR does not make it worse.
Reactor/Proactor single-threaded SynchronizationContext: ✅ no risk
- The worker runs on
TaskScheduler.Defaultand never onTaskScheduler.Currentor the context. WithDenyChildAttach, no awaits and no blocking waits, it cannot capture or post back to the pump's context. - On the caller's thread, the only extra work is two very short
lock (_cleanupStateLock)sections around field writes. The lock is never held during a scan, so a pump thread can only wait a few instructions on another caller or on the worker's take-and-clear. This is an improvement on85d3325's blockinglockand has the same cost as master'sTryEnter. - One point worth knowing: under sustained load with a short
ExpirationScanIntervaland a large box, the worker can loop and keep one pool thread busy for as long as requests keep arriving. That limit of one thread per box is what the design intends, and much better than a pile-up of threads.
_lastScanAt and _lastCompactionAttemptAt without synchronisation: ✅ not made worse, arguably better
- The worker never reads or writes either field; only callers do, as on master.
- The two races that already existed are now less harmful:
- Two callers can both pass the interval check. Before, that queued two scans. Now the two requests merge into one pass.
DateTimeOffsetis 16 bytes, so a torn read is possible. At worst it causes one extra request, which is merged, or one skipped request, which is retried on the next call after the interval.
- Moving both fields under
_cleanupStateLockwould be cheap. It is outside this PR's scope, though, so I would not do it here.
2. Tests (tests/Paramore.Brighter.InMemory.Tests)
The five new tests and the Blocking* doubles
The doubles are well built:
volatile _armedmeans the fill-up phase never blocks.- The
ManualResetEventSlimgate stays set once released, so later passes run straight through. - The 10s timeout on
Release*.Waitmeans a broken test cannot hang the run. - They override the
protected abstracthooks, which is the natural seam, and do not touch private state.
Do they fail reliably when the bug is present?
| Test | Against master (TryEnter + drop) |
After fix |
|---|---|---|
| Compaction contends with expiry (outbox and inbox) | Fails, provided the compaction task reaches TryEnter within the 500ms delay. If the pool is slower than that, the test can pass by mistake. The PR description already says this. |
Deterministic. The blocked worker takes the queued compaction on the thread it already has, so no second pool thread is needed once the gate is released ✅ |
| Expiry contends with compaction | Same as above | Deterministic ✅ |
| Coalesce into one scan | On master the dropped scans never reach the override, so the count is 1, not 6. The test still fails, because it expects 2. | Deterministic: exactly 2 ✅ |
| Expiry brings the box under the limit | On master, compaction is dropped and the count is 3, so this test passes against master | Deterministic ✅ |
85d3325 (blocking lock), not from master. That matters for two tests:
...should_coalesce_into_one_scanand...before_a_waiting_compaction_should_not_compactguard against the intermediate design's failure modes: threads piling up and over-trimming.- They do not guard against the original bug.
...should_not_compactis not RED against master at all.
Both are worth keeping as specifications of how the box should behave, but please relabel the column, for example "before fix (vs 85d3325)", or add a note. The same applies to the table in bugfix.md if it repeats these figures.
Behaviour or implementation?
- Four of the five tests assert on
EntryCountand on which entries survive, which is observable behaviour ✅ ...coalesce_into_one_scanasserts onExpiryScans == 2, a count of calls to a protected hook. It is the most tightly coupled to the implementation: it would reject any future design that runs one cheap scan per request. It is still the only guard against the pool pile-up seen in85d3325, so I would keep it. A comment on why the bound matters (one pool thread, bounded work) would help a future reader.
Coverage gap (minor). No test covers the count - newSize cap, meaning the case where expiry removes some entries but the box is still at or over the limit. Example: limit 5, request made at 10 entries (asks for 8 removals), expiry removes 3, leaving 7. The cap should remove 5 rather than 8, so the box ends at the target of 2 and not at 0. As things stand, deleting Math.Min would not turn any test red.
Nit: the new test files and Blocking* doubles have no MIT licence header, while ThreadPoolSaturatingCollection.cs does. .agent_instructions/documentation.md asks for headers on new files.
ThreadPoolSaturatingCollection: ✅ a sound fix, and no isolation is lost
- How it works: a collection with
DisableParallelization = trueruns after all parallel collections have finished, one test at a time. So the twoBarriertests can no longer starve anything that asserts on timing. Putting both classes in the same collection also stops them starving each other. - What it does not do: it fixes the symptom (they stop overlapping other tests) rather than the cause (tests whose progress depends on the pool growing). A stronger, optional alternative is to start the
Barrierparticipants withTaskCreationOptions.LongRunning, which gives each a dedicated thread, or withnew Thread(...). Neither then depends on pool growth, and they could run in parallel again. The collection approach is simpler and clearly correct, so I would accept it as it is. - Leaving the implicit
CommandProcesscollection: no isolation is lost."CommandProcess"has noCollectionDefinitionin this assembly. It is only an implicit name that makes the scheduler and sweeper classes run one after another.When_scheduling_message_with_existing_id_should_atomically_replace_timerbuilds all of its state per instance: its ownInMemorySchedulerFactory,FakeTimeProvider,InternalBus,InMemoryOutboxand aGuid-suffixedRoutingKey.InMemorySchedulerhas no shared static state apart from its logger, and the only statics inScheduler/are read-only type and reflection caches.- It also now runs with nothing else in parallel at all, which is stricter isolation than before.
Summary
| Area | Result |
|---|---|
| Handoff / no lost wakeup / at most one worker | ✅ Correct |
| Exception path | ✅ Flag is reset. |
CompactIfStillOverLimit |
✅ Correct for outbox and inbox. ➖ The count - newSize cap has no test |
| SynchronizationContext / pump safety | ✅ No risk |
| Unsynchronised timestamps | ✅ Not worse; the races that already existed are less harmful |
| New tests | ✅ Deterministic once fixed. 85d3325, and one test is not RED against master |
ThreadPoolSaturatingCollection |
✅ Sound, and no isolation lost |
None of these points blocks the merge. I made no code changes.
From review on #4482: if RemoveExpiredMessages threw, a compaction taken in the same pass was dropped, and a request recorded between the throw and the catch resetting the running flag was stranded for up to one interval. Each operation in a pass now runs in its own try/catch, so a failed expiry no longer skips the compaction, and the worker only ever stops in TryTakeCleanupRequests under the lock, so no request can be stranded. The first failure is rethrown once the worker stops, still surfacing as an unobserved task exception as before. Refs #4475 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 0038 Relabels the regression-test results by the InMemoryBox version they were measured against (master, blocking lock, single worker, final), since two of the newer tests pin faults of the intermediate design rather than of master. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…' into bugfix/4475-inmemory-flaky-tests
From review on #4482: no test covered CompactIfStillOverLimit's Math.Min(entriesRequested, count - newSize) cap, the case where expiry removes some entries but the box is still over its limit when a waiting compaction runs. Characterisation test; observed RED (Expected 2, Actual 1) under the mutation Compact(entriesRequested), then reverted. Refs #4475 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bles From review on #4482: .agent_instructions/documentation.md asks for the licence header on new files. No code changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the row for the target-size test, measured against each InMemoryBox version (master 8, blocking lock 1, single worker and final 2), and notes how its RED was observed under a named mutation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Symptom
OutboxCompactionDispatchedOnlyTests.When_compacting_only_dispatched_messages_in_outboxfails intermittently in CI with "Oldest dispatched message should be removed by compaction". The failed test takes exactly 2s, which is the length of its polling window, so compaction did not happen within that window.Root causes
There are two independent causes, and each can produce this symptom on its own.
1. Product defect:
InMemoryBoxsilently drops cleanup workMonitor.TryEnteron one shared lock and did nothing if the lock was held.EnforceCapacityLimitrecords the compaction attempt time before the task runs. So a dropped compaction still counts as an attempt, and the cooldown blocks any retry for a fullExpirationScanInterval. A dropped expiry scan is lost the same way, because_lastScanAthas already been updated.Add. WithEntryLimitor more entries, the O(n) expiry scan holds the lock long enough for compaction to lose the race repeatedly.EntryLimitthen goes unenforced.InMemoryInboxshares the same defect through the common base class.Add.2. Test-suite defect: two tests starve the thread pool
ConcurrentStartGuardTestsputs 20Task.Runwork items on aBarrier. The scheduler's same-id concurrency test puts 100.ConcurrentStartGuardTests.ConcurrentStartGuardTestswas added in June (af0501b35), after [Feat] InMemoryBox Expiry and Outbox limits #4116. That fits the issue's remark that the test "may have become more unreliable due to a change".Fix
InMemoryBox.cs: expiry and compaction are now recorded as requests and handled by at most one background worker per box.try/catch, so a failed expiry does not skip a compaction taken in the same pass. The first failure is rethrown once the worker has stopped, and still surfaces as an unobserved task exception, as before.EntryLimit, and it never trims below the target size.TaskScheduler.Defaultand contains noawaitor blocking wait. It never runs on a Reactor/Proactor pump's single-threadedSynchronizationContext, and at most one pool thread per box is used however heavy the load.85d3325used a blockinglock. Under load that holds a pool thread per waiting request, and those can pile up.10c28b1introduced the single worker.cd80f65hardened the failure path, following the review below.e72f0fa): the twoBarrier-based test classes now run in a non-parallelThreadPoolSaturatingcollection, so they no longer overlap other tests.Tests
The new tests hold the cleanup lock or worker deterministically through
Blocking*andFailingSecondExpiry*test doubles. Each one was measured against every version ofInMemoryBox.cson this branch (net9.0). The figures areEntryCount, except for the coalescing test, which counts expiry scans.4b2551785d332510c28b1Outbox/When_compaction_contends_with_expiry_…_should_still_compactInbox/When_inbox_compaction_contends_with_expiry_…_should_still_compactOutbox/When_expiry_contends_with_compaction_…_should_still_expireOutbox/When_expiry_is_requested_repeatedly_during_a_cleanup_should_coalesce_into_one_scanOutbox/When_expiry_brings_the_outbox_under_its_limit_before_a_waiting_compaction_should_not_compactOutbox/When_expiry_throws_in_the_same_cleanup_pass_as_a_compaction_should_still_compactOutbox/When_expiry_leaves_the_outbox_over_its_limit_before_a_waiting_compaction_should_compact_only_to_target§† On master this value depends on timing. Scans that reach the lock while it is held are dropped, but this test releases the lock straight after its reads, so they usually find it free and run. The blocking lock gives 6 every time.
‡ This test does not fail against master, because master drops the waiting compaction altogether. It pins a fault that the blocking lock introduced (over-trimming from an out-of-date count), not the original bug.
§ A characterisation test, added after review, for the
Math.Min(entriesRequested, count - newSize)cap inCompactIfStillOverLimit. The cap has been in place since10c28b1, so the test passed as soon as it was written. It was observed failing (Expected: 2, Actual: 1) under the mutationCompact(entriesRequested), and the mutation was then reverted. On master the compaction is dropped (8); on the blocking lock, compaction over-trims (1).These tests can still pass by mistake if the thread pool is extremely slow, but a slow pool can no longer make them fail.
Suite results:
Paramore.Brighter.InMemory.Tests: 160/160 on net9.0 and net10.0.Paramore.Brighter.Core.Tests(net9.0, after merging master): 1498 passed, 7 skipped, 0 failed.DOTNET_PROCESSOR_COUNT=2: green every run.The full diagnosis is in
bugfixes/0038-inmemory-outbox-compaction-flaky/bugfix.md.Fixes #4475
🤖 Generated with Claude Code