[#916] Keep an update that lands during a ServerState save out of the saved flag - #948
Conversation
…tate save out of the saved flag PersistentServerState.save() decided whether to write from a flag that the write itself cleared: runUpdateStateEntry() serialises the state once, at its top, and save() marked the state as saved only after the modify - which rewrites 99-user.ldif and takes hundreds of milliseconds - had completed. An update landing in between was lost: its CSN was not in the attribute being written, and the saved=false it had set was overwritten by the setSaved(true) of a write that did not carry it. Nothing set the flag again, so ds-sync-state stayed stale until the next meaningful update on the domain - which, on a domain as quiet as cn=schema, may never come. Mark the state as saved before taking the snapshot instead, so a racing update clears the flag again and the next checkpoint writes it, at the cost of at most one redundant write per race. That ordering only holds if the dirty marker is published after the mutation it advertises, so ServerState.update() now clears the flag once the new CSN is visible in the map rather than before touching it - which also stops a duplicate or older CSN from marking the state dirty for nothing - and clear() marks the state it empties as unsaved itself. Restore the flag when the write does not go through - a failure reported by the modify, or an exception on its way to the backend - which the previous code left dirty and the new ordering would otherwise mark saved for good. Saves also exclude each other now, on a lock of their own rather than on the monitor of the object, which loading the state already holds: two saves each took their own snapshot, and the write of the older one landing last left a state on disk that was both stale and marked as saved. A save with nothing to write returns before the lock, so it never queues behind another one's write. Loading a state that came in holding CSNs of its own no longer leaves it looking saved: the load only merges in what the backend holds, so what came with it is still owed to persistent storage. The old unconditional clearing in update() covered that by accident. This is what made SchemaReplicationTest.pushSchemaFilesChange fail intermittently in CI: a checkpoint write straddling the published change left the awaited CSN out of 99-user.ldif for the whole 10 s the test waits.
maximthomas
left a comment
There was a problem hiding this comment.
praise: The diagnosis is exact and the ordering contract is now right at both ends.
setSaved(true) is taken before the snapshot and saved = false is published after the map
mutation, in both CAS branches — so an update landing mid-write re-dirties the flag instead of being
swallowed. That makes all three ServerState mutators consistent (removeCSN() already had this
ordering). The runModify() seam turns a thread race into a deterministic test, the PR body's
"revert this line → this test fails" table is accurate (I checked writeThatFails* /
writeThatThrows*: they really do pin the finally), and the fix stays minimal on a 20-year-old
critical path. Filing #951 and #952 separately rather than folding them in is the right call.
issue (blocking): saveLock closes an AB-BA cycle with serviceStateLock.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/PersistentServerState.java:127
The backend modify now runs inside saveLock. When the baseDN entry is missing, it falls through
to the domain's cn=config entry — still inside the lock:
ResultCode result = runUpdateStateEntry(baseDN);
if (result == ResultCode.NO_SUCH_OBJECT)
{
SearchResultEntry configEntry = searchConfigEntry();
if (configEntry != null)
{
result = runUpdateStateEntry(configEntry.getName()); // under saveLock
}
}That write goes ConfigurationBackend.replaceEntry → synchronized (configLock) →
ConfigurationHandler.replaceEntry, which loops every ConfigChangeListener synchronously and
diffs nothing — so LDAPReplicationDomain.applyConfigurationChange runs, and its body is
synchronized (serviceStateLock).
T1 ServerStateFlush : saveLock -> config modify -> WANTS serviceStateLock
T2 disable() : serviceStateLock -> state.save() -> WANTS saveLock
disable() holds serviceStateLock across state.save(), and sets disabled only afterwards, so
the flusher's !disabled guard does not help. Reachable on import/restore into an empty backend:
processImportBegin / processRestoreBegin call disable(), and a missing baseDN entry is exactly
what routes the save to the config entry. shutdown() then blocks forever on the flushThread
monitor. At base save() took no lock, so T2 never queued.
Fix: take the snapshot under the lock, do the write outside it — or use
ReentrantLock.tryLock() and let a losing saver skip the tick, since the flag already guarantees
the next tick retries.
issue (blocking): the save lock's only test can pass with the lock deleted.
opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/PersistentServerStateTest.java:267
Exclusion is inferred from a latch that must time out:
secondWriteGotIn.set(secondWriteStarted.await(2, SECONDS));
...
assertFalse(secondWriteGotIn.get(), "the second save reached its write while the first one was still writing");
assertEquals(mostWritersInside.get(), 1, "two saves wrote the state at the same time");If the main thread needs more than 2 s to reach its own runModify (loaded CI, a GC pause), the
first write's await expires by itself, writersInside returns to 0, the second save takes the
else branch — and both assertions hold with the lock never contended. The failure direction is a
false green.
Blocking because the locking is going to be rewritten for the issue above, and this is the only test
that would catch the rewrite going wrong. Assert a positive observation (the second save found the
lock held), and fail when the contention window was missed rather than greening.
Two more in the same method, fix while you are there: the happy path always burns the full 2 s
(success is the timeout), and the main thread's state.save() blocks on saveLock untimed, so a
wedged lock hangs the suite instead of reporting.
note (non-blocking): the loadState() guard is unreachable in production.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/PersistentServerState.java:166
final boolean hadCSNs = state.iterator().hasNext();
...
if (hadCSNs) { state.setSaved(false); }Both src/main entries pass an empty state: LDAPReplicationDomain:3905 is
state.clearInMemory(); state.loadState();, and the constructor gets the ServerState that
ReplicationDomain created empty. Only the new unit test reaches it.
Keep it as defence in depth if you like — but a comment should say it is unreachable today, so it
does not read as the thing that makes the update() change safe. On the shipped paths that change
is safe for a different reason: merging into an empty map always mutates.
suggestion (non-blocking): clean up unconditionally.
opendj-server-legacy/src/test/java/org/opends/server/replication/plugin/PersistentServerStateTest.java:305
checkpointer.join(SECONDS.toMillis(30));
if (!checkpointer.isAlive())
{
state.clear();
}A wedged checkpointer skips the clear and leaves a serverId-1 CSN in o=test's ds-sync-state.
ReplicationTestCase is sequential and the sibling methods hard-code serverId 1, so one real
failure cascades into exact-CSN failures that report the wrong defect. Clear through a fresh
PersistentServerState, as the other finally blocks already do.
suggestion (non-blocking): cover the baseDN → config-entry fallback.
opendj-server-legacy/src/main/java/org/opends/server/replication/plugin/PersistentServerState.java:285
writeThatFails injects UNWILLING_TO_PERFORM, never NO_SUCH_OBJECT, so runModify is called
exactly once in every new test. The fallback takes a second snapshot, written reflects only the
second write, and the hook fires twice. It is also the path the deadlock above rides on.
nitpick (non-blocking): doc and API residue.
PersistentServerState.java:59— thesaveLockjavadoc justifies the dedicated lock by not
queueing behindcheckAndUpdateServerState()'s monitor, butsave()never tookthisat base
either; the rationale describes a constraint that never existed.ServerState.java:47— the newsavedjavadoc encodesPersistentServerState's protocol into a
shared value class incommonthat enforces none of it.ServerState.java:70—clear()dirties an already-empty, already-saved state, the opposite of
the discipline this same commit imposes onupdate(CSN):public void clear() { serverIdToCSN.clear(); saved = false; }
PersistentServerState.java:166—state.iterator().hasNext()whereServerState.isEmpty()
exists.
…ht rather than queue behind its write
|
Both blocking findings hold, and both are mine: the lock I added closes a cycle that was not there before, and the test I wrote to guard that lock cannot fail in the direction it was meant to. The lock is now taken with issue (blocking):
|
| disabled | fails |
|---|---|
tryLock() -> lock() |
aSaveGivesUpItsTurnWhileAnotherOneIsWriting |
the emptiness guard in clear() |
clearMarksTheStateUnsaved |
maximthomas
left a comment
There was a problem hiding this comment.
Reviewed at ec1d98aa against base 92d88ca6. The shape of the fix is right: deciding the flag from
a value the write itself invalidates is exactly the bug in #916, and setting saved before the
snapshot, restoring it in a finally, and moving the eager saved = false out of
ServerState.update()'s pre-loop into the successful branches all follow from that. tryLock()
rather than lock() is also right — applyConfigurationChange() reaches serviceStateLock with
configLock held, so a save that waited would close an AB-BA cycle.
My conclusion first: I would merge this as it stands. It removes a loss that fires on every save
overlapping an update — continuously on a busy domain, and permanently on one that goes quiet after a
burst, since the exit save then skips on the same flag — and it introduces no new failure class:
saveLock is tryLock-only, so nothing ever waits on it and no existing lock order can get worse,
and everything I found below is bounded by a resend and a duplicate replay rather than by divergence.
The only thing I would ask for before the merge is a correction to one row of the mutation table
(point 2 below), which is a change to the description and not to the code.
The rest is follow-up material. The first item is the one I care about, and I think it belongs in
the #951 fix rather than in this PR.
Follow-up: the save that steps aside is never made good at the callers that have no next save
PersistentServerState.java:134 and :139. Not a merge blocker — the closing paragraph of this
section says why.
save() can now return having written nothing, two ways:
if (state.isSaved()) { return; }is true for the whole duration of another save's in-flight
write, becausesetSaved(true)at:160precedesupdateStateEntry();if (!saveLock.tryLock()) { return; }.
The javadoc answers this with "the next save writes it". That holds for the ServerStateFlush
checkpoint loop, which comes round every second. It does not hold for the other three callers, and
LDAPReplicationDomain.java is not in this diff, so the change reads as safe locally and is not:
disable()(:3869-3873) isstate.save(); state.clearInMemory(); disabled = true;— what this
save does not write is dropped from memory one statement later, anddisabledis set only
afterwards, so the checkpointer is not held off while it happens;backupStart()(:4110-4113) is a singlestate.save();under the javadoc "We need to make sure
that the serverState is correctly save.";- the
ServerStateFlushexit save (:576) is the last write of the process.
Concrete interleaving on disable():
- the checkpointer takes
saveLock, setssaved = true, and freezes its snapshotSon the first
line ofrunUpdateStateEntry(); - a replay thread commits and calls
state.update(C)fromRemotePendingChanges.commit()— under
its own lock, notserviceStateLock— so the flag goes back to false andCis in memory but
not inS; disable()callssave(), sees the state dirty, losestryLock(), and returns without writing;clearInMemory()dropsC;- the checkpointer's write lands
S, withoutC.
On re-enable, loadDataState() reads the older watermark. Usually that is absorbed — the RS resends
the window and the historical information resolves the duplicates — but if the changelog has purged
it, the replica is declared out of date and needs a full reinitialisation.
How much worse than base this is, precisely: base had no lock, so disable()'s save and the
checkpointer's save ran concurrently as two REPLACEs of the same attribute. disable() always
issued a write carrying C, but which of the two landed last was not guaranteed by construction —
only by the checkpointer's operation having started earlier and therefore usually taking the entry
lock first. So base preserved C in the common interleaving and lost it in the reordered one, while
the head loses it in every contended case. The regression is that a conditional loss becomes a
certain one, not that a safe path became unsafe. Related and smaller: when the lock holder's write
fails, base gave a second attempt (disable()'s own); the head gives none.
Suggested fix, keeping tryLock() and adding no lock edge — either give save() a boolean return
and have disable() clear memory only when it is true, or have the save that steps aside set a
saveRequested flag that the lock holder re-reads before unlock() and answers with a second pass.
Either way the javadoc's "Giving up the turn loses nothing" needs qualifying for the terminal
callers.
Why I would not hold the merge on it: it needs a rare administrative operation — disable(),
backupStart() or shutdown — to land inside the checkpointer's few-millisecond write out of a
one-second period, with a replay update in flight; and the worst of the three legs is already broken
deterministically today by #951, where a disable() not followed by an enable() before
shutdown erases ds-sync-state outright. Both directions suggested there — set disabled before
dropping the state, or hold the save lock across the drop — close this leg as well, which is why the
make-good belongs in that fix. #945 also reorders disable() so the drain and disabled = true
precede state.save(), narrowing the same leg. backupStart() and the exit save are untouched by
either.
Smaller points
-
clear()/clearInMemory()javadoc no longer matches the code.ServerState.clear()resets
savedonly when the map was non-empty, so on an already-empty statePersistentServerState.clear()
writes nothing while its javadoc says "Empty the ServerState and write the emptied state to
persistent storage", andclearInMemory()'s new javadoc says the marking happens unconditionally.
Latent —clear()has no caller in main today — but the two javadocs are what a future caller
will read. -
One row of the mutation table credits the wrong test. For
if (!written) { state.setSaved(false); }the table lists the pre-existing
persistentServerStateTest. Running that mutant: the cases that fail are
writeThatFailsLeavesTheStateUnsaved,writeThatThrowsLeavesTheStateUnsavedand
writeWithNoBaseEntryAndNoConfigEntryLeavesTheStateUnsaved— exactly three of the nine —
whilepersistentServerStateTestpasses in both parametrisations ([o=test]and[cn=schema]).
This is the one point I would ask for before merging, since it is the PR's own evidence record and
costs no code change. -
The inner re-check is reached by no test.
if (state.isSaved()) { return; }at:146has no
row in the table and no case reaches it: the second save always losestryLock()first, and every
other method in the class is single-threaded. So "every production change was watched failing" is
one hunk short. Deleting the line costs one redundant modify, so this is a claim/coverage point
rather than a defect — but as it stands the line is the only unpinned production change in the PR. -
The ordering the fix rests on is pinned by nothing. A mutant that keeps the no-op semantics
but movessaved = falseback above theputIfAbsent/replaceCAS inServerState.update()
passes both changed test files. No test anywhere drives a singleServerStatefrom two threads —
the racing update inupdateLandingDuringSaveIsWrittenByTheNextSaveruns on the saving thread,
insiderunModify. Andupdate((CSN) null)inupdateThatChangesNothingKeepsTheStateSaved
passes at base as well, since the removedsaved = falsesat below the null guard; only that
case's duplicate-CSN assertion actually fails at base. One test that drives two threads over one
ServerStatewould pin the property the whole change is about.
Two issues this PR does not close
- #951 —
disable()followed by a shutdown persists an emptyds-sync-state, because
clearInMemory()leaves the state marked dirty and the checkpointer's exit save at:576is
unconditional. Unchanged here, in either direction; mentioning it because a reader of this PR may
reasonably expect theclearInMemory()rework to have touched it. - #952 — the new
finally { if (!written) { state.setSaved(false); } }does leave the state
correctly dirty when the write throws, andwriteThatThrowsLeavesTheStateUnsavedpins that. The
throw still escapessave(), and theServerStateFlushloop catches onlyInterruptedException,
so the checkpointer thread still dies on it.
For the record on the test side: PersistentServerStateTest is 9/9 green at this head (102.98 s),
and aSaveGivesUpItsTurnWhileAnotherOneIsWriting completes in 0.038 s, nowhere near its 30 s
self-release — no wedge risk there.
Fixes #916.
The race
PersistentServerState.save()decided whether to write from a flag that the write itself cleared.runUpdateStateEntry()serialises the state once, at its top, and the flag was only set after the modify — which rewrites99-user.ldifand takes hundreds of milliseconds — had completed. An update landing in between was lost: its CSN was not in the attribute being written, and thesaved = falseit had set was overwritten by thesetSaved(true)of a write that did not carry it. Nothing set the flag again, sods-sync-statestayed stale until the next meaningful update on the domain — which, on a domain as quiet ascn=schema, may never come.The CSNs in the CI failure of #916 pin the window: the persisted state carries the replayed change from 19:38:31.846 but not the local one from 19:38:32.247, so the snapshot was taken between the two and the write completed after the later one, marking the state saved on behalf of a write that did not carry it.
The change
save()marks the state as saved before taking the snapshot, so an update landing during the write clears the flag again and the next checkpoint writes it — at the cost of at most one redundant write per race.ServerState.update()clears the flag once the new CSN is visible in the map rather than before touching it. As a side effect a duplicate or older CSN no longer marks the state dirty for nothing.tryLock()and never waited for: a save which finds another one writing gives up its turn. Waiting would close a lock cycle — a write which goes to the domain configuration entry ends up inLDAPReplicationDomain.applyConfigurationChange(), which takes the very lockdisable()holds while callingsave(). Giving up the turn loses nothing, because the state is marked as saved before the snapshot in flight is taken: whatever the save which stepped aside had to write is either already in that snapshot, or has cleared the flag again after it was set, in which case the flag is still clear when that write completes and the next save writes it.ServerState.clear()marks the state it empties as not saved itself — and, likeupdate(), only when it actually removed something. Loading a state that came in holding CSNs of its own no longer leaves it looking saved: the load only merges in what the backend holds. The old unconditional clearing inupdate()covered both by accident.runModify()is split out ofrunUpdateStateEntry()as a seam, so a test can reach the point where the snapshot has been taken but the write has not gone through yet.Testing
Every production change was watched failing first, through a direct mutation of the committed code:
save()updateLandingDuringSaveIsWrittenByTheNextSavesaved = falseafter the map mutationupdateThatChangesNothingKeepsTheStateSavedif (!written) setSaved(false)writeThatFailsLeavesTheStateUnsaved,writeThatThrowsLeavesTheStateUnsaved,writeWithNoBaseEntryAndNoConfigEntryLeavesTheStateUnsaved, and the pre-existingpersistentServerStateTestsaved = falseinclear(), and its emptiness guardclearMarksTheStateUnsavedtryLock()replaced by a blockinglock()aSaveGivesUpItsTurnWhileAnotherOneIsWritingloadState()stateLoadedOverCSNsOfItsOwnIsNotConsideredSavedaSaveGivesUpItsTurnWhileAnotherOneIsWritingholds its write open until the second save has run and been checked, so the exclusion is a constructed fact rather than a timing window: the second save must return while the first write is provably still in flight, must not have reached the write, and must leave the state for the next save — which then puts the newer CSN on disk. The write in flight releases itself after 30 s, so a save which waits reports rather than wedging the suite.Green on JDK 21 (
mvn -Pprecommit verify):PersistentServerStateTest9/9 (the race test runs against botho=testandcn=schema),ServerStateTest6/6,SchemaReplicationTest3/3 — includingpushSchemaFilesChange, the intermittently failing test from the issue —ReplicationDomainTest12/12,GenerationIdTest4/4,InitOnLineTest10/10,UpdateOperationTest15/15. ThesetUpfailures seen locally were another test run on the same machine holding the fixed test ports, and each of those classes passes on its own.Not covered here
Three pre-existing hazards this change does not touch, all unchanged by it:
disable()runssave()andclearInMemory()non-atomically, and the flush thread's exitsave()is unconditional, so an emptied state can reachds-sync-state.clearInMemory()'s javadoc now says so.ServerStateFlush, andshutdown()then waits onwhile (!done)with no timeout.serviceStateLockandconfigLockdeadlock against each other on the fallback path, and did so before this PR: a write to the domain configuration entry reachesapplyConfigurationChange()— and soserviceStateLock— withconfigLockheld, whiledisable()holdsserviceStateLockacross asave()which wantsconfigLock. It is one lock ordering over two other files, so it is not touched here.The second write of the
baseDN-> configuration-entry fallback has no test. Reaching it needs ads-cfg-replication-domainentry over the suffix, which would start a live domain with a checkpointer of its own writing the same state under every other method ofPersistentServerStateTest; it belongs on a class which already runs a domain.