Replace per-operation backend read lock with a scalable shared-access gate#680
Open
vharseko wants to merge 4 commits into
Open
Replace per-operation backend read lock with a scalable shared-access gate#680vharseko wants to merge 4 commits into
vharseko wants to merge 4 commits into
Conversation
… gate Every operation enters the pluggable backend through accessBegin() and EntryContainer.sharedLock: one AtomicInteger increment/decrement pair plus a ReentrantReadWriteLock read acquire/release per operation. Read locks do not block each other, but every acquire/release CAS-es the single lock state word, and at tens of thousands of operations per second across all worker threads this cache line becomes the hottest shared write in the server. Hot paths in BackendImpl now register through a striped LongAdder gate (beginSharedAccess/endSharedAccess) and only wait when an exclusive locker has closed the gate. Exclusive lockers (index removal, backend configuration changes, close — all rare structural operations) close the gate via the existing lock()/unlock() helpers, drain in-flight accesses, and still take the write lock, which also excludes the remaining legacy sharedLock readers (export, verify, tree listing). threadTotalCount becomes a LongAdder (read only by waitUntilQuiescent). Interleaved A/B under the compare benchmark: +13% throughput on the cleanest adjacent pair (39.2k -> 44.3k ops/s) and consistently better p99.9 (26 -> 19 ms; 135 -> 54 ms late in the series), every pair favoring the gate. 1,443 tests pass, including JETestCase and the import/export suites exercising the exclusive paths.
maximthomas
reviewed
Jul 3, 2026
Contributor
There was a problem hiding this comment.
Suggested change
| container.beginSharedAccess(); |
maximthomas
reviewed
Jul 3, 2026
Contributor
There was a problem hiding this comment.
Suggested change
| container.endSharedAccess(); |
…gate Review follow-up: hasSubordinates() still acquired EntryContainer.sharedLock directly and was missed by the original conversion because its local variable is named differently. All hot BackendImpl paths now go through beginSharedAccess()/endSharedAccess().
Member
Author
|
Good catch — |
maximthomas
approved these changes
Jul 3, 2026
lock() used to break out of the drain loop on InterruptedException, allowing the exclusive caller (index removal, configuration change) to run concurrently with in-flight shared accesses. Defer the interrupt like beginSharedAccess() does: keep draining and restore the interrupt status before returning. Add a regression test that fails against the previous behavior.
…ped counter LongAdder gives no guarantee that the increment and the backout decrement of one beginSharedAccess() call land in the same cell: the probe is rehashed after CAS contention and the cell table grows. A non-atomic sum() scan may then observe the decrement while missing the paired increment, under-count and return a false zero while a shared access is still in flight, letting lock() start a structural operation concurrently. Replace both counters (sharedAccessCount, threadTotalCount) with a striped counter whose slot is a pure function of the thread, so a scan observes a prefix of each slot's history and can only over-estimate — the same-slot guarantee percpu-rwsem gets from per-CPU counters.
maximthomas
approved these changes
Jul 5, 2026
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.
Summary
Result of profiling the SEARCH hot path with the same methodology as the
rest of the series (#660, #667–#670, #672, #674, #678, #679). Every
operation of every type enters the pluggable backend through
BackendImpl.accessBegin()+EntryContainer.sharedLock:AtomicIntegerincrement/decrement pair(
threadTotalCount, carrying a long-standingFIXME: this is brokencomment), andReentrantReadWriteLockread acquire/release held for the wholeoperation body.
Read locks do not block each other, but every acquire/release CAS-es the
single AQS state word — at tens of thousands of ops/s across all worker
threads this cache line is the hottest shared write in the server, and
unlike the previous fixes it sits on the path of all operations
(search, bind, compare, modify, add, delete).
Change
The percpu-rwsem pattern, encapsulated in
EntryContainer:BackendImpl(10 sites, includinghasSubordinates— converted in a review follow-up commit, thanks @maximthomas) register throughbeginSharedAccess()/endSharedAccess(): a striped counter incrementplus a volatile gate check (CAS + volatile ordering makes the
Dekker-style store-load pairing safe); they park on a monitor only while
an exclusive locker has the gate closed. The counter is a same-slot
StripedCounter, not aLongAdder— see the second review follow-upbelow.
container close: all rare structural operations — go through the
existing
lock()/unlock()helpers, which now close the gate, drainin-flight accesses and still take the RRWL write lock. The write lock
also keeps excluding the remaining legacy
sharedLockreaders (export,verify, tree listing, entry counting), which stay untouched.
threadTotalCountbecomes aStripedCounter(only read bywaitUntilQuiescent()during backend shutdown).Benchmark
Same harness as the series: packaged server,
jebackend, 5,000 users,JDK 11, 8-core host,
caffeinate, warm-up run after every restart,interleaved order old→new→new→old→old→new. Compare load (the highest
operation rate through the backend, hence the most gate traffic):
The shared host fades thermally through the series (visible in both
variants), yet every adjacent pair favors the gate: +13.0% on the
cleanest pair (slots 1↔2) and +37.4% late in the series (slots 5↔6), with
consistently much better tail latency. Errors: 0 in all runs.
Review follow-up (e540fb6)
EntryContainer.lock()no longer abandons the drain loop on interrupt:returning early could let the exclusive caller (index removal,
configuration change) run concurrently with in-flight shared accesses.
It now records the interrupt, keeps draining until all shared accesses
finish and restores the interrupt status before returning — the same
pattern
beginSharedAccess()uses. Covered by a new regression testtestExclusiveLockDrainsSharedAccessDespiteInterruptinPluggableBackendImplTestCase: it holds a shared access, interrupts athread blocked in
lock()and assertslock()does not return untilendSharedAccess(). Verified both ways: fails deterministically againstthe previous behavior, passes with the fix (
JETestCase— now 33 tests —0 failures).
Review follow-up (a9f7ecc)
The drain originally counted in-flight accesses with a
LongAdder, whichis not provably safe as a quiescence barrier:
LongAdderdoes notguarantee that the increment and the backout decrement of one
beginSharedAccess()call land in the same cell (the thread's probe isrehashed after CAS contention, the cell table grows). A non-atomic
sum()scan may then observe the decrement while missing the pairedincrement, under-count and return a false zero while a shared access is
still in flight — letting
lock()start the structural operationconcurrently. (An 8-minute targeted stress did not reproduce this on
x86-class hardware, but it is a JMM-permitted execution, and a
correctness barrier needs a provable invariant, not an unobserved race.)
Both counters are now a
StripedCounter: a paddedAtomicLongArraywhose slot is a pure function of the thread, so the increment and the
decrement of one access are guaranteed to hit the same slot — the
guarantee percpu-rwsem gets from per-CPU counters under
preempt_disable(). A scan reads each slot once, i.e. observes a prefixof each slot's history, in which a decrement can never appear without
its earlier paired increment; every per-slot subtotal is therefore
non-negative and
sum()can only over-estimate, never return a falsezero. The full argument is in the class javadoc.
Testing
mvn -P precommit -pl opendj-server-legacy verify— 1,443 tests, 0failures:
AddOperationTestCase(137),CompareOperationTestCase(37),DeleteOperationTestCase(194),ModifyOperationTestCase(932),SearchOperationTestCase(75),JETestCase(32 — import/export/verify/index operations exercising the exclusive↔shared interplay),
OnDiskMergeImporterTest(29),TestImportAndExport(7).Files
opendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/EntryContainer.javaopendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/BackendImpl.javaopendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/RootContainer.javaopendj-server-legacy/src/main/java/org/opends/server/backends/pluggable/StripedCounter.java(new)opendj-server-legacy/src/test/java/org/opends/server/backends/pluggable/PluggableBackendImplTestCase.java