Repository navigation
Disabling a domain can persist an empty ds-sync-state and lose the replica's position #951
Description
Activity
- addedconcurrencyThread-safety / race-condition bugsThread-safety / race-condition bugs
on Sep 7, 2026 Walked the code for this. Every mechanical claim holds; three things are missing from the description, one of the two interleavings is reachable far more easily than it says, and the Impact section is more generous than the code warrants.
Confirmed
disable()isstate.save(); state.clearInMemory(); disabled = true;(LDAPReplicationDomain.java:3868-3873), andclearInMemory()isstate.clear(); state.setSaved(false);(PersistentServerState.java:281-285).serviceStateLockbuys nothing here: the checkpointer synchronises on itself —synchronized (this)at:561, theServerStateFlushinstance, which is also the monitorshutdown()notifies on at:2371. The two threads share no lock at all.- The exit save at
:576is unconditional. checkAndUpdateServerState()returns onstate.getCSN(serverId) == null(PersistentServerState.java:314-318) and cannot repair. Worth adding that even without that guard it only ever repairs the local serverId: nothing local records how far the replay of a peer got, so the peers' entries in the state are unrecoverable by construction.- The write is a
REPLACEbuilt fromstate.toASN1ArrayList()(:255), which on an empty state is a zero-value list —ds-sync-stateis not written empty, it is removed from the base entry.
The justification for
clearInMemory()'s semantics does not holdThe description defends
setSaved(false)as "whatPersistentServerState.clear()wants, since it exists to write the empty state out".clear()has no production caller — the only one in the tree isPersistentServerStateTest.java:86. Both production callers ofclearInMemory(),disable()andloadDataState()(:3905), want the opposite.So this is not "give
PersistentServerStatea drop operation distinct fromclearInMemory()". It is thatclearInMemory()has had the wrong semantics all along, and the test-onlyclear()should clear the flag itself.Scope, and a third window
disable()has exactly two production callers, both inMultimasterReplication(:638,:664), reached only fromImportTask(:628) andRestoreTask(:263) viaDirectoryServer.notifyImportBeginning/notifyRestoreBeginning. The online total update does not go through them. Naming that makes the second interleaving concrete.It also makes it much easier to reach than "a shutdown following a
disable()": both tasks firenotify*Beginningand then, ifTaskUtils.disableBackend()fails,return TaskState.STOPPED_BY_ERRORfrom before thetry/finallythat firesnotify*Ended. The domain is then disabled for the life of the server, holding an empty dirty state, and any shutdown after that writes it out. Filed as #966 — it is a defect in its own right, since replication for that base DN stays silently off.restore --verifyOnlygoes through the same path:notifyRestoreBeginningfires before theverifyOnlybranch, so a dry-run verify on a replicated backend disables and re-enables the domain with the data untouched. The only thing at risk there is the position.And there is a third clear-then-load window the description does not list:
loadDataState()(:3903-3907) does the sameclearInMemory()thenloadState(), and in the total-update path it runs at:4359withdisabled == false.ieRunning()is true acrossimportBackend(ReplicationDomain.java:2522-2551), so the periodic save is gated there — but the exit save at:576still is not, and the gap is not a few instructions:loadState()runs a base search pluscheckAndUpdateServerState()'s historical search.The impact is worse than "the safe direction"
loadDataState()follows the load withgetGenerator().adjust(state.getMaxCSN(getServerId())). On an empty state that argument isnull, andCSNGenerator.adjust(null)(CSNGenerator.java:120-130) setslastTime = TimeThread.getTime(); seqnum = 0— the only path in the generator that moveslastTimebackwards.adjust(CSN)never lowers it (if (lastTime <= rcvdTime) lastTime = ++rcvdTime,:143-145).So the re-enabled domain can issue CSNs it has already used, by exactly the margin that peers with a faster clock had pushed
lastTimepast the local clock — which is whatadjustexists to do. A change carrying a CSN older than the historical value the peers already hold for that serverId loses conflict resolution and is dropped. That is divergence, not re-replay. It needs clock skew (or two changes inside one millisecond) to bite, but the emptied state is what arms it.Suggested direction, refined
The second option in the description, but simpler than it is framed: make
clearInMemory()leave the state marked as saved — it drops the in-memory copy, the backend still holds the truth — and let the test-onlyclear()mark it unsaved before saving. That coversdisable(), theenable()gap and the total-update gap at:4359in one place, with no reordering indisable().Then, belt and braces, give the exit save at
:576the same guard as the loop body. Redundant for this bug once the flag is right, but it is what stops a future clear-then-something from leaking through shutdown.Interaction with #948
#948 makes
ServerState.clear()setsaved = falseitself, so the fix above becomesstate.clear(); state.setSaved(true);insideclearInMemory()— which reads oddly against that new contract and will want a comment saying why.Its
saveLockdoes not close this race either. It adds an interleaving: the checkpointer passes the!disabledcheck, enterssave(), finds the state dirty, and parks on the lock for the whole duration of the backend write thatdisable()is doing — then acquires it, re-checks, and finds the state emptied and dirty.Two additions from walking the task side for #966, both about this issue rather than that one.
The CSN generator regression does not need the race at all
Above I described
adjust(null)movinglastTimebackwards as the consequence of re-enabling a domain whose state was emptied by an interleaving. It is reached without any interleaving, on every failed import or restore, because the tasks fire the end notification twice and the first one cannot work.ImportTask.java:673/:688callnotifyImportEnded(..., false)and then return through thefinally, which callsnotifyImportEnded(..., true)at:732;RestoreTask.java:297/:303and:338are the same shape. OneprocessImportBegin, twoprocessImportEnd, twoenable()s.The first
enable()runs before the backend is put back — that happens only atImportTask.java:719:loadState()'s base search returnsNO_SUCH_OBJECTfor a deregistered backend (PersistentServerState.java:152-166), so the reload finds nothing and the state stays empty;getGenerator().adjust(state.getMaxCSN(getServerId()))(LDAPReplicationDomain.java:3907) is thereforeadjust(null), andlastTimedrops to the local clock withseqnum = 0;loadGenerationId()then finds nothing either and falls intocomputeGenerationId()→exportBackend(), whosegetBackend()isnullwhile the backend is deregistered, so the exception lands inenable()'s catch (:3931-3937) and the domain staysdisabled == true.
The catch does not undo the generator adjustment. The second notification, after the backend is back, is what actually re-enables the domain; its
adjust()liftslastTimeagain only if the state it reloads is non-empty — and after a failed import the backend may well have been cleared already.So the emptied in-memory state is not the only way into the CSN reuse hazard: a disabled backend supplies the same emptiness to
loadDataState(), deterministically, every time a task fails after its backend was disabled. TheclearInMemory()fix does not touch this one; firing the end notification exactly once, from a point where the backend is up, is the task-side half. Filed under #966.What an empty state costs is more than re-replay
I noted that
checkAndUpdateServerState()returns onstate.getCSN(serverId) == nulland can only ever repair the local entry. The other half of that mechanism is worth naming: the local entry it repairs is what feeds the missing-change publisher.sessionInitiated()starts anRSUpdateronly whenstate.getMaxCSN(getServerId())is newer than the RS's CSN for this replica (LDAPReplicationDomain.java:4767-4788).Both guards fail on an empty state, and they are the pair that recovers changes the replication server never received — the local writes a replica took while it was disconnected or disabled. Those are not re-replayed by anyone; they are simply not published. So the impact of a persisted empty
ds-sync-stateis not only "everything the replication server still holds is sent again", which is the safe direction, but also the silent loss of the changes it does not hold, which is not.Both are covered by making
clearInMemory()leave the state marked as saved — the local CSN survives the drop, so the reload has something to repair from.- addeddata-lossData integrity / loss of entriesData integrity / loss of entriesand removed
on Sep 8, 2026 A third road to the same symptom, found while reviewing #945 (which is for #908, not for this issue).
Unlike the two interleavings in the description, this one is not a race: it fires every time.A second
disable()saves the state the first one cleareddisable()has no idempotence guard, and an onlineimport-ldifon a replicated backend reaches it
twice:ImportTask.java:628 DirectoryServer.notifyImportBeginning(backend, importConfig) -> MultimasterReplication.java:671 domain.disable() // #1 ImportTask.java:633 TaskUtils.disableBackend(backend.getBackendID()) -> BackendConfigManager -> LDAPReplicationDomain.java:279 performBackendPreFinalizationProcessing -> domain.disable() // #2LDAPReplicationDomainregisters itself as a local backend initialization listener at:716, and the
guard at:279-285is!ignoreBackendInitializationEvent && !serverShutdownRequested && backendID matches.ignoreBackendInitializationEventis set inpreBackendImport(), which is the total
update road, not this one, andserverShutdownRequestedis false. Nothing stops the second call.disable()#1 runsstate.save()— which writes the real state — and thenstate.clearInMemory()
(:3889-3890), which empties the map and doessetSaved(false). Nothing repopulates it in between:
the only reload isloadDataState()fromenable(), and that isprocessImportEnd, after the import.
Sodisable()#2 runsstate.save()on the cleared state —!isSaved()is true,updateStateEntry()
serialises an emptyServerState, andds-sync-stateis REPLACEd with no values.Where it becomes visible is the road where the import then does not replace the data:
ImportTask:650fails to take the exclusive lock ->:688 notifyImportEnded(..., false)->
:719 TaskUtils.enableBackend->enable()->loadDataState()reads an emptyServerStateover
intact data.RestoreTaskreachesdisable()only once — it has nonotifyRestoreBeginningcounterpart on that
road — so this one is specific toimport-ldif. It is the same lines as #966: that issue is about the
domain never being enabled again, this one about the state being erased on the way in.An idempotence guard on
disable()— an early return insideserviceStateLockwhendisabledis
already true — closes this road on its own and costs nothing on the other two.What PR #945 does and does not close here
#945 is for #908 and does not claim this issue, but it touches two of the three roads:
- it reorders
disable()todisabled = true; disableService(); sessionGeneration++; awaitReplayDrained(); state.save(); state.clearInMemory();, i.e. the flag is set before the state is
dropped — the first bullet of "Suggested direction" above; - it guards the exit save at
:576withif (!disabled && !importInProgress()), which closes the
second interleaving deterministically.
It does not close the first interleaving, only narrows it onto the exit save. The guard is two plain
reads outside the flush thread'ssynchronized (this), anddisable()holdsserviceStateLock, which
the flush thread never takes, sovolatileondisabledbuys visibility and not atomicity. The flush
thread can pass both reads, be descheduled, and have a wholedisable()run before itsstate.save()
builds the modify — the exposed span runs toPersistentServerState:231 runUpdateStateEntryreading
the values.shutdown()does not join the flush thread first: it waits ondoneafter the final
save, andflushThread.initiateShutdown()precedesderegisterLocalBackendInitializationListener, so a
concurrentimport-ldif/restore/dsconfigbackend-disable can run that wholedisable()inside
the gap.And it does not close the double-
disable()road above, which does not involve the flush thread at all.- it reorders
Walked this road against the code. It holds, and the flag fix in #970 closes it. Two corrections, and what the pull request now carries.
Confirmed
Both calls are on the lines named.
ImportTask.java:628 notifyImportBeginning->MultimasterReplication.java:671 domain.disable(), then:633 TaskUtils.disableBackend->applyConfigurationChangewithenabled:false->BackendConfigManager.java:828 deregisterBackend->:1156-1158 performBackendPreFinalizationProcessing->disable()again. Worth adding why the secondsave()can still write: the listener fires beforeconfiguredBackends.remove()andderegisterLocalBackend(), so the backend is still registered and the internal modify does reach the base entry.ignoreBackendInitializationEventis set only bypreBackendImport()(:4332), the total update road, and the guard at:281-283has nothing else to stop.restoretakes the same pairRestoreTaskreachesdisable()twice as well::263 notifyRestoreBeginning, and then:270 TaskUtils.disableBackend(backendID)underif (!verifyOnly). Onlyrestore --verifyOnlytakes a singledisable(), since that is the branch which skips the backend disable. So the road is not specific toimport-ldif. What is specific is which failure leaves the data in place: for a restore it is alockBackend()which does not take or arestoreBackup()which throws, after which:325 TaskUtils.enableBackendre-enables the domain over data the restore never touched.One line reference
On the lock-failure road the task returns at
ImportTask.java:653and goes straight into the outerfinally-:719 TaskUtils.enableBackend, then:732 notifyImportEnded(..., true).:688is thecatch (Exception)aroundimportLDIF, a different road into the samefinally. The conclusion stands, only the arrow in the middle points elsewhere.The guard, and what #970 carries
The idempotence guard is not needed for this symptom. With
clearInMemory()leaving the state marked as saved - which is what #970 does - the drop no longer marks it unsaved, so thesave()of the seconddisable()has nothing to write; same for a checkpoint and for the exit save. An early return would be a second mechanism under the same assertion, on the lines #945 rewrites, so #970 does not add it.What #970 now carries is the regression test for this road,
DisabledDomainServerStateTest.aDomainDisabledTwiceKeepsItsPersistedState: a disconnected domain takes a local change,disable()saves the position, and a seconddisable()must leaveds-sync-statealone. Watched failing against the previousclearInMemory()-the second disable() dropped the position saved in ds-sync-state: lists don't have the same size expected [1] but found [0]- and green with it:DisabledDomainServerStateTest2/2,PersistentServerStateTest4/4,InitOnLineTest10/10,ReSyncTest2/2 on JDK 11.On #945
Agreed that its reorder narrows the flush-thread window rather than closing it, and that it leaves the double-
disable()road untouched. One detail: the residue is not only on the exit save - the save in the loop has the same check-then-save window,if (!disabled && !ieRunning())andstate.save()being two steps adisable()can run between; what the reorder buys there is that a passing checkpointer now hasdisableService()and the replay drain to complete inside, rather than two instructions. The two changes touch different files, so whichever lands first, the other still applies.
Symptom
Disabling a replication domain can end with an empty
ds-sync-stateon the base entry, i.e. the replica loses its replication position. On the nextenable()(or on a restart) the domain resumes from nothing:loadState()finds no CSN, andcheckAndUpdateServerState()cannot repair it either, because it returns immediately whenstate.getCSN(serverId)is null. Everything the replication server still holds is then sent again and replayed a second time.Found while working on #916; it is a separate defect on the
disable()path, present before and after the fix for that issue.The mechanism
disable()empties the in-memory state without keeping the checkpointer away from it:clearInMemory()empties the map and marks the state as not saved — which is whatPersistentServerState.clear()wants, since it exists to write the empty state out, but not whatdisable()wants.serviceStateLockis not held by the checkpointer, so between the three statements above theServerStateFlushthread can entersave(), find the state dirty, snapshot the now-empty map and REPLACEds-sync-statewith an empty attribute.Two interleavings reach it:
!disabled && !ieRunning()(:564) as true just beforedisabled = trueis set, then takes its snapshot afterclearInMemory()has run;:576) callsstate.save()unconditionally, without consultingdisabledorieRunning(), so a shutdown following adisable()writes the emptied state whatever the flags say.The window in the first case is a few instructions wide; the second one is not a race at all — a
disable()that is not followed by anenable()before shutdown persists emptiness every time.Impact
The safe direction (changes are replayed rather than skipped), but the whole point of the persisted state is defeated: after a disable/enable cycle or a disable followed by a restart, the replica re-requests everything the replication server still holds. On a busy domain that is a large replay; the changes are applied again as duplicates, which the historical information resolves, but the work and the window of divergence are real.
Suggested direction
The in-memory copy and the flag that says "this must be written" are cleared together by
clearInMemory(), which is right forclear()and wrong fordisable(). Options, roughly in order of how localized they are:disable()setdisabled = truebefore dropping the state, and make the flush thread's exitsave()respect it — the exit save exists to flush a last checkpoint, which is pointless once the state has been dropped on purpose;PersistentServerStatea "drop the in-memory copy, the backend keeps what it holds" operation distinct fromclearInMemory(), which leaves the state marked as saved;A regression test can drive the second interleaving directly: disable a domain, then let the flush thread run its exit path, and assert that the base entry still carries the CSNs the domain had.