Repository navigation
Bound LightEpoch waiter semaphore signals to prevent SemaphoreFullException - #2139
Conversation
LightEpoch.Release() signalled waiterSemaphore once per epoch release for as long as any thread was waiting for a table entry, but each waiter consumes exactly one signal. ProtectAndDrain() hops through SuspendResume() on every refresh while a waiter exists, so releases vastly outnumber waiters under contention and the semaphore's count climbed monotonically. At int.MaxValue SemaphoreSlim.Release() began throwing SemaphoreFullException out of every epoch release, killing client sessions and permanently stopping the object collect and expired key deletion tasks until the process was restarted. Track outstanding signals in pendingWaiterSignals and only signal while it is below waiterCount. The reservation is taken before the semaphore release and returned after a successful wait, so the semaphore's count can never exceed the number of waiters. Suppressing a signal cannot strand a waiter: a signal is only withheld when every current waiter already has a wake pending, and a woken waiter re-probes the whole table before blocking again. Also stop the two background tasks in StoreWrapper from terminating for the lifetime of the process on an unexpected exception. The per-cycle work is now guarded so a failed cycle is logged and retried on the next interval.
There was a problem hiding this comment.
🟡 Changes recommended
The documented waiter invariant is inaccurate, and the new background-task retry behavior lacks regression coverage.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Bounds LightEpoch semaphore signals to prevent overflow and keeps maintenance tasks alive after transient failures.
Changes:
- Adds bounded waiter-signal accounting and concurrency tests.
- Makes collection and expiration tasks retry failed cycles.
- Documents epoch-table waiting behavior.
File summaries
| File | Description |
|---|---|
LightEpoch.cs |
Adds bounded signal reservations. |
LightEpoch.TestHooks.cs |
Exposes waiter metrics to tests. |
WaiterSignalTests.cs |
Tests signal bounds and liveness. |
StoreWrapper.cs |
Retries failed maintenance cycles. |
epochprotection.md |
Documents waiter signaling. |
Review details
Suppressed comments (1)
libs/storage/Tsavorite/cs/src/core/Epochs/LightEpoch.cs:572
- The claimed
pendingWaiterSignals <= waiterCountinvariant is racy. After the slot is published, a waiter may acquire it at line 686 and leave the slow path while this signaler has already incrementedpendingWaiterSignalsbut has not yet released the semaphore; once that waiter decrementswaiterCount, pending can exceed the current waiter count. Please base the explanation on the actual peak-waiter bound and account for stale, self-draining reservations.
/// The increment is what reserves the right to signal, so it always precedes its
/// <see cref="SemaphoreSlim.Release()"/> and the matching decrement always follows a successful wait.
/// The semaphore's count is therefore never above <see cref="pendingWaiterSignals"/>, which this loop
/// holds at or below <see cref="waiterCount"/>.
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
CompactionTaskAsync wrapped its whole loop in a single catch, so any exception from a compaction cycle ended the task for the lifetime of the process. Guard the per-cycle work instead, so a failed cycle is logged and retried on the next interval, matching the object collect and expired key deletion tasks. Cancellation is now caught as OperationCanceledException and suppressed rather than logged as an error, so shutdown no longer reports a spurious failure.
Correct the documented bound on pendingWaiterSignals. A waiter can claim the freed slot on its re-probe and decrement waiterCount while a reservation taken for it is still in flight, so the cap is against the peak number of concurrent waiters, not the instantaneous count. A leftover signal is consumed by the next waiter, which decrements and re-probes, so leftovers drain rather than accumulate. Any such bound prevents the overflow; the stronger invariant the comments and docs asserted does not hold. Extract the shared retry loop used by the compaction, object collect and expired key deletion tasks into RunMaintenanceLoopAsync. The three loops were identical after the resilience change, and a single seam makes the contract testable so it cannot silently regress to a terminal catch. Add MaintenanceLoopTests covering that contract with fault injection: a failed cycle is logged as an error and the loop runs again, repeated failures keep retrying without a terminal log, cancellation ends the loop without faulting or logging, and a cycle cancelled during shutdown is not logged as a failure.
…ter-semaphore-fix
Records that comparing the semaphore's CurrentCount against waiterCount is a read-then-act with no atomicity against the Release that follows, and that the resulting surplus is never reclaimed, so the reservation counter is the bound that holds.
…ter-semaphore-fix
|
Many thanks for taking a look at this one. In the meantime, could you please confirm this changes in the conf file will fix this issue? If not, could you please let us know if there is an actual workaround for this situation? Ideally, from conf file for existing customers and in a future release we will update to latest version that will have this fix. Thank you! |
|
Gabriel Tarpian (@gabrieltarpian25) — From CoPilot: Short answer: Why the thread cap works
Instrumenting
Uncapped accumulates ~37.5k signals/s, reaching Why 120 over 96A session parks a worker thread in
Both values prevent the bug equally; 96 just throttles the server harder. Pick the highest value safely under 128 — 120 leaves 8 spare slots, which was never encroached in any run. Two caveats: Why
|
Status update: conflicts resolved, and a full answer to the review finding on the signal boundTwo things were outstanding on this PR: a conflicted merge with 1. Merge conflict with
|
| Test | Asserts |
|---|---|
AFailedCycleIsLoggedAndTheLoopKeepsRunning |
a throwing cycle is logged and the loop runs the next cycle |
TheLoopKeepsRunningWhenEveryCycleFails |
repeated failures never terminate the loop |
CancellationEndsTheLoopWithoutFaultingOrLogging |
shutdown is clean and silent |
ACycleCancelledDuringShutdownIsNotLoggedAsAFailure |
a cycle cancelled mid-flight is not misreported as an error |
These were verified to be genuinely load-bearing rather than vacuously green: reverting RunMaintenanceLoopAsync to a single terminal catch turns AFailedCycleIsLoggedAndTheLoopKeepsRunning and TheLoopKeepsRunningWhenEveryCycleFails red. The same method was applied to the epoch fix — reverting SignalWaiter produces 4,652 unconsumed signals against a bound of 136 in about 0.4s.
4. Validation, and one inherited breakage to be aware of
On the merge result: Tsavorite.test.epoch 18/18, MaintenanceLoopTests 4/4, and both dotnet format --verify-no-changes checks (Garnet.slnx and Tsavorite.slnx) clean.
main, not from this PR. Tsavorite.test.csproj fails to compile on pristine origin/main (5c6e595):
TestBase.cs(46,9): error CS0234: The type or namespace name 'TestUtils'
does not exist in the namespace 'Garnet.test' [Tsavorite.test.csproj]
Root cause: Tsavorite.test.csproj links exactly one file out of Garnet.test — TestBase.cs (Tsavorite.test.csproj:14) — and #2151 added a Garnet.test.TestUtils.EnsurePortSlotResolved() call to TestBase.cs while TestUtils.cs is not linked into that project. Garnet.test.cluster links both files and so is unaffected.
This blocks every PR, not just this one, and it also blocks Tsavorite.test.epoch, which project-references Tsavorite.test. The 18/18 epoch result above was obtained by guarding that one call behind a compilation symbol locally; that workaround was reverted and is not part of this branch, since the fix belongs with #2151 rather than buried in an unrelated PR. No upstream fix appears to be in flight yet.
Separately, RespTests.MultipleClientsUnblockAndAddTest and ClusterVectorSetTests.VectorSetMigrateManyBySlot are known flakes on main unrelated to this change; re-running the failed jobs on an identical commit previously went green at 226 passed / 0 failed.
microsoft#2145), LightEpoch semaphore bound (microsoft#2139)
Fixes #2135.
Problem
LightEpoch.Release()signalledwaiterSemaphoreonce per epoch release for as long as any thread was waiting for a table entry, but each waiter consumes exactly one signal:The two rates are unrelated and wildly asymmetric, because
ProtectAndDrain()hops throughSuspendResume()on every refresh while a waiter exists:So a handful of waiters causes every protected thread to emit a signal on every refresh.
waiterSemaphoreisnew SemaphoreSlim(0)(maxCountint.MaxValue), so once the count reachesint.MaxValueeveryRelease()executed while a waiter exists throwsSemaphoreFullException.The throw happens after the slot is freed, so the epoch table stays consistent — but the exception unwinds into the caller: client sessions die in
ProcessMessages(andDispose()throws again throughStorageSession.Dispose()→SuspendResume()), and the object-collect / expired-key-deletion tasks exit permanently. Nothing decrements the count, so only a process restart recovers.Reported in production as ~47 h clean, then 24,622
SemaphoreFullExceptions over 4 days at a steady 126–414/hour. Trigger is sustained concurrency abovekTableSize = max(128, ProcessorCount * 2).Fix
Track outstanding signals in
pendingWaiterSignalsand only take a reservation while it is belowwaiterCount:The reservation is taken before the semaphore release and returned after a successful
Wait(), so the semaphore's count is capped by the peak number of concurrent waiters —O(threads)rather than unbounded — and the overflow is unreachable.The cap is against the peak rather than the instantaneous count: a waiter can claim the freed slot on its re-probe and decrement
waiterCountwhile a reservation taken for it is still in flight, briefly leaving a signal outstanding with no waiter present. Such a leftover is consumed by the next waiter, which decrements and re-probes, so leftovers drain instead of accumulating.I used an explicit counter rather than the
waiterSemaphore.CurrentCount < waiterCountcheck suggested in the issue: that check is a racy read-then-act under concurrent releases, and it leaves stale signals behind that accumulate across contention bursts.Liveness. Suppressing a signal cannot lose a wakeup:
pendingWaiterSignalsfollows the release-store that publishes the slot as free, so a waiter woken by an already-outstanding signal sees it.Dispose()sets thewaiterCountMSB, making it negative, so no signal is issued and waiters unwind throughcts— unchanged behaviour.SignalWaiter()isNoInliningso the CAS loop stays out ofRelease()'s inlined body. Under contention this is also slightly cheaper than before, since it skipsSemaphoreSlim.Release()(which takes an internal lock) whenever a signal is already pending.Background task resilience
ObjectCollectTaskAsync,ExpiredKeyDeletionScanTaskAsyncandCompactionTaskAsynceach wrapped their whole loop in onecatch (Exception)that logged and returned, so a single transient fault disabled that maintenance task for the process lifetime. The per-cycle work is now guarded and retried on the next interval;Task.Delayalready provides the backoff.Since all three loops became structurally identical, the shared loop is extracted into
StoreWrapper.RunMaintenanceLoopAsync. That removes the triplication and gives one seam where the retry contract can be tested with fault injection rather than a live server and real timers.Compaction also no longer logs a spurious error on shutdown: cancellation is caught as
OperationCanceledExceptionand suppressed, matching the other two.Tests
libs/storage/Tsavorite/cs/test/test.epoch/WaiterSignalTests.cs— the helper fills every table slot, confirms the surplus threads are parked on the semaphore, and only then releases the churn, so oversubscription is deterministic rather than timing-dependent.SignalCountStaysBoundedWhenTheTableIsOversubscribed— asserts the outstanding signal count stays within the waiter bound across 270k+ releases.EveryThreadCompletesWhenTheTableIsOversubscribed— liveness guard against a lost wakeup, with far more threads than slots so nearly every release is a suppression candidate.SignalsDrainAfterContentionEnds— asserts signals do not accumulate across three successive bursts.Verified these catch the bug by temporarily reverting the fix: 4,652 unconsumed signals against a bound of 136 in ~0.4 s of contention, and 858 leaked after a single burst. The liveness test passes either way by design — it guards this change rather than the original bug.
test/standalone/Garnet.test/MaintenanceLoopTests.cs— covers the retry contract directly with an injected fault:AFailedCycleIsLoggedAndTheLoopKeepsRunning— first cycle throws; logged once atError(notCritical), and a later cycle runs.TheLoopKeepsRunningWhenEveryCycleFails— every cycle throws; the loop is still running on the third and never logs terminally.CancellationEndsTheLoopWithoutFaultingOrLogging— the task completes successfully and logs nothing.ACycleCancelledDuringShutdownIsNotLoggedAsAFailure— a cycle that throws because of cancellation is shutdown, not a failure.Verified these catch a regression too: reverting the helper to a single terminal catch fails the two retry tests.
Validation
Tsavorite.test.epoch(net10.0 + net8.0)Tsavorite.test(full)Garnet.testRespTestsGarnet.testMaintenanceLoopTestsBasicLockTests/ExpiredKeyDeletionTests/HashCollectdotnet build(full solution)dotnet format --verify-no-changes(both solutions)Docs
website/docs/dev/tsavorite/epochprotection.mdgains a Waiting for an epoch table entry section covering the probe strategy, thewaiterCountre-probe that closes the releaser race, the peak-waiter signal bound and its self-draining leftovers, andDisposehandling. It also corrects the existing claim that "If the epoch table is full, the rest of the threads will keep yielding", which describes the spin-wait replaced in #1543.