Repository navigation
[#1206] Take a late handler of a replication server back to its queue only once its consumer has every change the queue lacks - #1219
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
praise: The gate sits on both roads where #1206 lost changes, and the tests prove what the description says they prove.
queueLacksUpTorecords both sources of loss: the domain state at the first handout (MessageHandler.java:160-166) and every change a full queue drops (queueLacksUpTo.update(msgQueue.removeFirst().getCSN()),:173). It is checked on the empty-read road (:388) and on the first-change-found road (:422-424).MessageHandlerCatchUpTestmeasured on JDK 21.0.2: 8/8 green at the head. WithMessageHandler/MsgQueuefrom master, cases 1-5 fail on their send assertions and 6-8 pass, as described. Description mutants (a) record at every handout, (b) count offline messages and (c) never go back on the first change are each killed by exactly the cases the table names.
issue (blocking): MsgQueue.isCoveredBefore() has no exemption for a queued change that the changelog no longer holds, so the writer thread can re-read the changelog with no wait.
opendj-server-legacy/src/main/java/org/opends/server/replication/server/MsgQueue.java:194-207, MessageHandler.java:411-424
Take a late DS handler whose queue still holds a change C1 of replica A that its consumer lacks, at the moment a generation id is reset. clearDbs() empties the changelog but no handler queue (MessageHandler.java:809, in shutdown(), is the only msgQueue.clear()). changeStatusForResetGenId puts the DS in BAD_GEN_ID, so nothing is handed to the queue that could evict C1. Then a replica C that wrote after the clear shuts down cleanly before the handler's first empty read. From then on, every fresh cursor re-serves C's ReplicaOfflineMsg: FileChangelogDB.getOfflineCSN() (:843-854) returns it whenever the start CSN is older, and updateServerState() never records its CSN. The non-contributing road (:413-418) moves it into msgQueue, and isCoveredBefore(offline, serverState) fails on C1. lateQueue is now empty and following false, so while (activeConsumer) opens a new cursor at once. The writer thread spins until C writes again or the DS gets a new session. On master, contains(msg) alone set following, consumeUpTo() dropped C1 and the handler waited on its queue. A peer-RS handler spins the same way between arrivals until a full queue evicts C1. The description says "A change the changelog no longer holds ... is left out", and consumerHasWhatTheQueueLacks() does leave it out (newest != null && ...); isCoveredBefore() does not. I traced this by reading the code; I did not run it. The trigger is a timing window, so it is rare.
// MsgQueue.java
public boolean isCoveredBefore(UpdateMsg msg, ServerState state, ServerState inTheChangelog)
{
synchronized (lock)
{
for (UpdateMsg before : map.headMap(msg.getCSN()).values())
{
final CSN csn = before.getCSN();
final CSN newest = inTheChangelog.getCSN(csn.getServerId());
final CSN sent = state.getCSN(csn.getServerId());
if (before.contributesToDomainState() && !state.cover(csn)
&& newest != null && (sent == null || newest.isNewerThan(sent)))
{
return false;
}
}
return true;
}
}
// MessageHandler.java:422-424
if (msgQueue.contains(msg)
&& consumerHasWhatTheQueueLacks()
&& msgQueue.isCoveredBefore(msg, serverState, replicationServerDomain.getLatestServerState()))A change that arrives late with an older CSN (case 4) is written to the changelog before add(), so newest > sent for its replica and it still holds the handler back. The other gate, consumerHasWhatTheQueueLacks() (:188-200), already has this exemption; one getLatestServerState() snapshot can feed both.
Pin: a MessageHandlerCatchUpTest case that queues a contributing change of a replica the changelog holds nothing of, ahead of a ReplicaOfflineMsg the changelog serves. At the head getNextMessage() never returns; with the fix the handler follows its queue.
suggestion (non-blocking): No test pins the (sent == null || newest.isNewerThan(sent)) arm of consumerHasWhatTheQueueLacks().
opendj-server-legacy/src/main/java/org/opends/server/replication/server/MessageHandler.java:195
This arm is what lets a peer-RS handler go back to its queue after its full queue dropped a ReplicaOfflineMsg (ReplicationServerDomain.java:440 queues them for RS handlers). The drop records the offline CSN in queueLacksUpTo, and no consumer state ever covers that CSN. Every lacked CSN the eight cases build is at most the changelog's newest for its replica, so the arm never decides anything. Measured: this mutant passes 8/8 at the head (JDK 21.0.2).
// survives MessageHandlerCatchUpTest 8/8
if (!serverState.cover(lacked) && newest != null && true)Pin: hand a small queue a ReplicaOfflineMsg of replica B, then enough changes of A to drop it, with a consumer that has every change of B in the changelog. The handler must follow its queue again. It does at the head and stays on the changelog under the mutant.
suggestion (non-blocking): Once the queue has been handed a change, nothing pins the empty-read road back to the queue.
opendj-server-legacy/src/main/java/org/opends/server/replication/server/MessageHandler.java:388
None of the eight cases reaches the true arm of isMsgQueueBelowThreshold() && consumerHasWhatTheQueueLacks() with a non-empty queueLacksUpTo. MissedUpdateRecoveryTest.aPeerReplicationServerIsLeftAlone never hands its peer handler a change, so the peer-RS one-time read the description promises ("reads those ... once ... before it goes back to its queue") has no test either. Measured: this mutant passes 8/8 at the head (JDK 21.0.2). Under it, a handler that has been handed a change never follows again from an empty read and re-reads the changelog with no wait.
// survives MessageHandlerCatchUpTest 8/8
if (isMsgQueueBelowThreshold() && !queueHandedAChange)Pin: a case that hands the queue a change, lets the consumer catch up from the changelog until a read gives nothing, and asserts that the next change handed is served from the queue. Running the same case with a replica the queue is never handed also covers the peer-RS read up to the first-handout snapshot.
question (non-blocking): Is a duplicate ECL notification on the peer acceptable for the peer-RS one-time read?
opendj-server-legacy/src/main/java/org/opends/server/replication/server/MessageHandler.java:160-166
The first-handout snapshot includes replicas behind a third RS, and put() never hands those to a peer-RS handler (ReplicationServerDomain.java:362). So the handler sends the peer every such change up to the snapshot from the changelog. The description says the peer "takes no change it holds". Its replica DB does drop the copy silently (Log.java:448-452), but FileChangelogDB.publishUpdateMsg() (:919-928) calls notifyCookieEntryAdded regardless, so each copy reaches the peer's cookie-mode persistent searches a second time. This happens at most once per peer session, and master re-sends a shorter stretch the same way. It stays Minor whatever the answer, but the description should name this consequence.
suggestion (non-blocking): A third road back to the queue has no gate, but the description says "Both ways back to the queue check what the queue lacks".
opendj-server-legacy/src/main/java/org/opends/server/replication/server/MessageHandler.java:452-458, :653-670
When lateQueue and msgQueue are both empty and findOldestCSNFromReplicaDBs() returns null, following is set with neither check. After a successful read this is equivalent to the gate: null means no replica holds a contributing record after the consumer's state. But the method's catch (ChangelogException e) also returns null (:666-669), and then the handler goes back to its queue unchecked, as on master. Gate it like the other two roads, or say in the comment at :443-447 and in the description why it needs no gate.
// MessageHandler.java:455, inside synchronized (msgQueue)
if (nextChange == null && consumerHasWhatTheQueueLacks())nitpick (non-blocking): The queueLacksUpTo javadoc says the domain hands a registered handler every change it receives, which is false for two kinds of handler.
opendj-server-legacy/src/main/java/org/opends/server/replication/server/MessageHandler.java:84-86
put() hands a ReplicationServerHandler only DS-originated changes (ReplicationServerDomain.java:362), and hands a DS in BAD_GEN_ID or FULL_UPDATE status nothing (isUpdateMsgFiltered, :471-498). The description adds that such a DS ends that status "with a new session ... or once the changelog is cleared for its generation id". On the reset road it returns to NORMAL on its live handler (DataServerHandler.changeStatusForResetGenId, :106-124, called at ReplicationServerDomain.java:2088), with no new session. Name the changes the domain withholds in the javadoc, and drop the reset road from the description's new-session claim.
…er back to its queue only once its consumer has every change the queue lacks A replication server stopped sending a directory server the changes of another replica, and went on minutes further: the changes in between were in the changelog of every replication server and never reached that directory server, and missing-changes still reached 0. It happened to directory servers catching up under load, and to directory servers which had just connected again. A handler which is behind reads the changes for its consumer from the changelog, and getNextMessage() takes it back to its in-memory queue once the queue holds the first change of what it read: the oldest change the consumer lacks. The queue is ordered by CSN and drops its oldest changes when it is full, so holding that change does not mean holding the ones after it. A change which reaches the queue late with an older CSN - relayed by another replication server, or published again by a recovery - goes in front of the changes of another replica which the queue dropped before it came, or never had, as they reached the domain before the handler was registered. That change is then the oldest one the consumer lacks, and it is in the queue: the handler went back to the queue, the consumer was given the changes the queue still held of the other replica, and its state moved past the ones in between. The changelog is only read again for a change the state of the consumer does not cover (OpenIdentityPlatform#963), so they were never sent. A handler connected for long keeps such a change in a full queue because the queue is bounded in bytes as well and drops whole changes: the room a large change leaves takes a small one in. The changelog is read without the lock of the queue, so the same holds for a change which arrives in between. Arrived with an older CSN than the first change read, it was dropped along with the changes before that one, which the consumer has. Arrived into a full queue as its oldest change, it was dropped at once, and a handler whose read gave nothing went back to the queue all the same. That fits what OpenIdentityPlatform#1206 found. The replica skipped was connected to the same replication server as the directory server, while the replicas whose changes came through the other replication server were not skipped. Both jumps of its description landed on the last 10 to 13 s of the skipped replica, what the queue held of it. The new handlers of cases 2 and 3 of its comment skipped from the moment they connected: their queues had nothing from before. The handler now records, for each replica, the newest change of it the queue may lack: what the domain held when it handed the queue its first change - the domain hands a registered handler every change it receives, and writes a change to the changelog before it hands it over - and every change the queue drops when it is full. It goes back to the queue only once the state of the consumer covers them, and once the queue holds nothing before the first change read that the consumer lacks; it reads the changelog until then. Both ways back to the queue check what the queue lacks: the one which finds the first change read in the queue, and the one taken when the read gave nothing. A change the changelog no longer holds, as one of a changelog cleared for a new generation id, cannot be read from it either: it is left out once the consumer has every change of its replica the changelog holds. A replica offline message in the queue is left out as well: no state of a consumer ever holds its CSN. When the queue holds every change after the state of the consumer, nothing changes: what it lacks is older than the oldest change the consumer lacks. For the handler of a peer replication server, what the domain held includes changes of replicas connected to other replication servers, which the domain never hands it. The handler reads those the state of the peer lacks from the changelog once, up to what the domain held then, before it goes back to its queue; the peer gets them from the replication servers they come from as well, and takes no change it holds. Changes the domain does not hand a handler on purpose - to a directory server with another generation id or being initialized - are not recorded: such a directory server ends that status with a new session, after its import or once the changelog is cleared for its generation id. Not addressed here, from the comment on OpenIdentityPlatform#1206: the span all three streams skipped after the changes sent again at the reconnection of 23:08:57 (case 4), and RemotePendingChanges.putRemoteUpdate() taking a change sent again which the state of the directory server covers. MessageHandlerCatchUpTest drives a handler no domain feeds from one thread, as the writer of a session does; a change arrives as ReplicationServerDomain.put() hands it over, written to the changelog then given to the queue, and the domain of a handler can hand it a change right after it has read the changelog: - a queue which dropped a change of a replica, then took in a change of another replica with an older CSN: the consumer is sent the dropped change; - a queue which never had the changes of a replica which reached the domain before it, then got a change of another replica with an older CSN, with a later change of the first replica and with none: the consumer is sent them; - a change with an older CSN than the first change read, which arrives once the changelog is read: the consumer is sent it; - a change a full queue drops at once, which arrives once a read gave nothing: the consumer is sent it; - the queue of the first case for a consumer which has the dropped change, and a queue which dropped a change the changelog no longer holds: the handler goes back to the queue; - a replica offline message in the queue does not keep the handler from it. Master fails the first five. Each of these fails at least one of the eight: leaving out the dropped changes, what the domain held, the check of the queue before the first change read, or the check when the read gave nothing; recording what the domain holds at every change handed over; counting a change the changelog no longer holds, or a replica offline message; never going back to the queue.
…gelog again and again for a change of its queue no read can give Review of OpenIdentityPlatform#1219, round 1. Before a late handler goes back to its queue, it checks that the queue holds nothing before the first change read which the consumer lacks. That check counted a change the changelog no longer holds, as one of a changelog cleared for a new generation id, which the check of what the queue lacks already left out. A queue which still held such a change, with a changelog which gave nothing but a replica offline message - every read gives it again, as no state of a consumer holds its CSN - kept the handler reading the changelog again and again, with no wait: its writer spun until the replica wrote again or the consumer got a new session. On master the handler went back to its queue there. Both checks now count a change only when the consumer lacks it and the changelog holds a change of its replica the consumer lacks, against one state of the changelog. MsgQueue.isCoveredBefore() became holdsAChangeBefore(), which takes that condition. The third way back to the queue, taken when both queues are empty and nothing is found after the state of the consumer, checks what the queue lacks as well: findOldestCSNFromReplicaDBs() finds nothing also when the changelog cannot be read, and the handler then reads it again instead. The javadoc of queueLacksUpTo names the changes the domain does not hand a handler: a peer replication server is handed only the changes of the directory servers connected to this one, and a directory server nothing while its status is bad generation id or full update. MessageHandlerCatchUpTest: - a queue which holds a change the changelog no longer holds, with a changelog which gives nothing but a replica offline message: the handler goes back to its queue, where it never returned before; - a full queue which dropped a replica offline message, for a consumer which has every change of its replica: the handler goes back to its queue; - a handler which caught up from the changelog until a read gave nothing, and one which first read the change of a replica its queue is never handed: the change handed after that read is served from the queue; - the replica of the offline message of the case of round 1 now writes a change the consumer lacks after it: the offline message does not count all the same.
…es of the changelog only of a change its replica DB took FileChangelogDB.publishUpdateMsg() notified the cookie based persistent searches of cn=changelog of every change it was given, also of one its replica DB did not take: Log.append() drops a record whose key is not after the newest one, which is what a change the changelog already holds is, and did not tell so. A change a replication server receives twice - for one, sent by the late handler of a peer replication server reading its own changelog - reached those searches twice, as their persistent phase lets every CSN through. LogFile.append(), Log.append() and FileReplicaDB.add() now tell whether the record was appended, and publishUpdateMsg() notifies the persistent searches only of a change the replica DB took. ChangelogBackendTestCase: a directory server publishes a change, the same change again, then a newer one, and a changes-only cookie based persistent search is notified of each once; on master it is notified of the first one twice. LogTest: a record whose key is not after the newest one is not appended, whether the head log file is rotated or not, and append() says so.
ad3e21a to
7335990
Compare
|
Round 2: issue (blocking), The two checks now share one condition, Case 8 had to change with it. With the exemption, an offline message of a replica the changelog holds nothing of passes whether or not the suggestion, the suggestion, the empty-read way after a first handout: pinned.
Your question, duplicate ECL notification: yes, and fixed here as #1224. Confirmed as you traced it.
The read itself stays. Leaving the replicas behind a third RS out of the snapshot would need to tell them from a replica which was connected here and moved away: its earlier changes reach a peer only from this RS. The description names the consequence now. suggestion, the third way back to the queue: checked now, as you proposed. After a successful read the check changes nothing. When No test pins this check, and its mutant passes all 12 cases. I found no state the handler reaches where the check decides on that way: after the first handout, both queues are empty while the handler is behind only once nitpick, the The second half I kept, with references added to the description. Both ways out of those statuses take a new session:
Mutants, in the description: 12 of Reactor, |
maximthomas
left a comment
There was a problem hiding this comment.
praise: The round-1 loop is closed where it arose, and every mutant row of the description holds when re-run.
consumerLacksFromTheChangelog(MessageHandler.java:216-221) counts only the changes a read can still give, on all three roads back to the queue (:408-409,:446-448,:481-482). Case 9,aQueuedChangeTheChangelogNoLongerHoldsDoesNotHoldTheHandlerBack, fails against round 1'sMessageHandler/MsgQueuewith the head's test (JDK 26).- Re-run at the head, JDK 26: dropping
(sent == null || newest.isNewerThan(sent))fails case 10;!queueHandedAChangeon the empty-read road fails cases 11 and 12; droppingbefore.contributesToDomainState()inMsgQueue.holdsAChangeBeforefails case 8;replicaDB.add(updateMsg) || truefails the newChangelogBackendTestCasecase, with the duplicate notified twice. - #1224 is fixed at its source:
Log.appendandFileReplicaDB.addreturn whether they appended, andFileChangelogDB.publishUpdateMsg(:926) notifies the cookie persistent searches only ontrue.
… give credit and to read once the broker is being stopped, and write nothing after a message given up (#1210) Fixes #1202 Fixes #1209 Fixes #1222 ## Summary `ReplicationBroker.stop()` could not end while its replication server kept the session up and took no update from it, and with it hung `ReplicationDomain.disableService()`, which calls `stop()` holding `serviceStateLock` and `sessionLock` - the shutdown of the domain, an import, a restore, a session restart. It hung in two ways: waiting for window credit the replication server did not give (#1202), and, once the socket buffers were full, in a write to the session which the replication server did not read (#1209) - with the default window of 100000 updates, the buffers fill long before the window closes. The stop now gives the replication server a bounded time to give credit, and as long again to read what was written, after which it closes the session as a failed one; once a publish gave its message up for the stop, the broker writes nothing more. The same write held the session with no stop at all once the replication server was gone without a reset (#1222): the heartbeat monitor, which then closes the session, waited for it as well, until TCP gave up retransmitting. It now closes such a session as a failed one too. ## Root cause - The one flag which ends the wait of a publish for window credit is `shutdown` (`while (!done && !shutdown)` in `ReplicationBroker.publish()`), and `stop()` set it only after `domain.publishReplicaOfflineMsg()` returned. - `ReplicaOfflineMsg` extends `UpdateMsg`: it takes a permit of the send window like a change. With the window spent, the stop waited for the credit of its own replica offline message even with no local change in flight - wider than the case #1202 describes. - In the case of #1202, a local change waits for credit inside `PendingChanges.commitAndPushCommittedChanges()`, under the monitor `PendingChanges.putReplicaOfflineMsg()` needs: the stop waits for that change, which waits for credit. - Credit comes only as the replication server takes the updates of the session, while its heartbeats come from a thread of their own and keep the session up. Nothing else ended the wait. - A directory server writes its session on the thread which publishes - its `Session` runs no publisher thread - and a local change is published under the lock of the pending changes. A write which the replication server does not read blocks in the socket once the buffers are full, holding that lock, `connectPhaseLock` and the `publishLock` of the session (#1209). A socket write has no timeout, and `Session.close()` of a session with no error takes `publishLock` first: nothing on the directory server ended that write. - The heartbeat monitor, which closes the session once the heartbeats of the replication server stop, closed it with `Session.close()` too (#1222). A replication server which is gone without a reset acknowledges nothing: the writes fill the buffers, the next one blocks, and the close waited for it until TCP gave up retransmitting - while the listener stayed in a read which neither data nor a reset ends, and the local changes waited for the lock of the pending changes. ## Change `ReplicationBroker`: - `stop()` records the stop in progress, with a deadline, before it publishes the replica offline message: a `StopInProgress` in the `volatile` field `stopping`, which `start()` clears to `null`. The deadline is the connection timeout of the replication provider (`MultimasterReplication.getConnectionTimeoutMS()`, 5 seconds by default): the replication server gets as long to give credit as it gets to accept a connection. One which is only slow still takes the changes and the replica offline message, as before. A connection timeout of 0, which sets no limit to a connection or to an SSL negotiation, sets none to the stop either: the stop then waits as it did before. - A stop which overlaps one in progress keeps that one, its deadlines and what it gave up, rather than replacing it. - A publish which finds no credit once the deadline is past gives its message up and marks the stop (`StopInProgress.gaveUp`), which ends the publish loop for every later message as well, until `start()` clears the stop. Before it marks the stop it reads it again: if `start()` ran since, the loop head decides. What is given up: - a local change, which the next session republishes from the historical information of its entry - or a local delete, one the recovery publishes included, from the deletes the domain keeps aside in memory, which a restart of the server loses; - or the replica offline message, which `PendingChanges.putReplicaOfflineMsg()` then reports as not sent, its announcement withdrawn. - Nothing is written by `publish()` after a message given up: the replica offline message carries the newest CSN of the replica, and written after a change given up it would tell the replication server that nothing older is to come. - The give-up is marked on the stop, not on the broker. A publish which found the stop past its deadline may mark it only after `start()` cleared it - the read and the write are not atomic, and no lock is taken for them - and it then marks a stop which is over, rather than the broker started again, which would refuse every publish until its next stop. - `stop()` starts a watchdog for the stop, unless the connection timeout is 0 (`endWhatStillHoldsTheStop()`). If the stop is still held when it is past its credit deadline by as long again - twice the connection timeout from its start - what holds it is no wait for credit, which gives up by itself at the deadline, but a write the replication server does not read, or a lock such a write holds. The watchdog then marks the give-up of the stop, logs a warning which names the replication server, and closes the session as a failed one (`Session.abort()`): the write fails, and the publish which made it returns rather than retries. A stop which waits for credit and gives up still ends its session in order, with its `StopMsg`. - The watchdog acts under the monitor of the stop, and `stop()` ends the stop under `startStopLock` once it is done: the watchdog of a stop never closes a session of a broker started again. - A publish reads the give-up again under `connectPhaseLock`, before it writes a message and before it writes a window probe. A publish which passed the loop head before the give-up may wait for that lock behind a connection of the listener - one which the replication server ending the session in the stop set off, and which takes the connection timeout for each replication server that answers nothing - past the deadline of the watchdog, which then finds no session to close. Without that read the publish wrote to the session the connection opened, and a replication server which did not read it would hold the stop with nothing left to end it. The watchdog marks the give-up before it reads the session, and a connection sets its session before it releases the lock: either the watchdog closes that session, or the publish finds the mark. - Once the stop gave up, the broker opens no session again while the stop runs (`reStart()`): the listener, which the close of the session wakes, waits for the end of the stop rather than connects. A session opened then would carry nothing but its change time heartbeats - which tell the replication server that nothing older is to come from this replica, while it still has the changes given up - and the stop would wait for the connection. A connection already under way when the stop gives up still opens its session: `publish()` writes nothing to it, and the stop closes it as it ends. - `stop()` stops the broker in a `finally`: a failure of the replica offline message no longer leaves a stopping broker running, which would give up every publish that waits for credit. The watchdog is started inside the same `try`: a thread which cannot be started leaves no stopping broker running either. `Session.abort()` (new) records the error, marks the close begun, closes the sockets, and then closes the session, which takes the branch of a failed session: it writes nothing more - neither what the publisher thread left queued nor the `StopMsg` - and takes no lock. The sockets closed first end a write blocked in them, whichever thread makes it - the publisher thread, which any close joins, included - and release a close which had begun before, with no error, and waits for the lock of the writes. The close is marked begun before them, so that the listener whose read their close ends finds it begun, and does not report it as a failure of the replication server. `HeartbeatMonitor` closes a session whose heartbeats stopped with `Session.abort()` (#1222): the blocked write and the read of the listener fail, and the reconnection proceeds as for any session which failed; no `StopMsg` is written, which a replication server whose heartbeats stopped is not expected to read. A monitor stopped with its broker still closes the session as before. The javadoc of `ReplicationDomain.publish(UpdateMsg)` and the trace of `LDAPReplicationDomain.publishReplicaOfflineMsg()` name the new reasons a message is not published, and the javadoc of `DSRSShutdownSync.replicaOfflineMsgNotSent()` counts the wait of a stopping broker in the window between an announcement and its withdrawal. The synopsis of `connection-timeout` (`ReplicationSynchronizationProviderConfiguration.xml`) says that it also bounds the wait of a stopping domain for credit, and its description says when a domain is stopped, what raising or lowering the value does to such a stop, that a replication server which does not read what was written gets as long again before the session is closed, and that 0 sets no limit. ## Not changed - A publish held up by an `IOException` still retries until the broker is connected again or finds no replication server to connect to - unless the stop gave up. - A recovery publish given up is refused to the recovery, as one refused once `shutdown` is set (#1204): the round ends, and the changes from it - a delete included, which stays aside - are left to the recovery of the next session. - An assured change given up for the stop still waits for its acknowledgment until the assured timeout, and is counted as sent and timed out. Ending that wait early needs a mark of its own in the assured machinery: a CSN taken out of the messages which wait for an acknowledgment reads as acknowledged. - A change given up comes back with the recovery of the next session, which runs in a thread of its own, and the change time heartbeats of that session do not wait for it. When the recovery takes longer than a heartbeat interval, the replication server takes a heartbeat as "nothing older is to come" from this replica, and the change gets its change number after newer changes of other replicas. Nothing is lost; any change a recovery publishes does the same on master. - The change time heartbeats, and the window and status messages written straight to the session, are not held back after a give-up: only `publish()` reads the mark, until the session is closed. That holds for the session a connection already under way at the give-up opens too, until the stop closes it as it ends. - A replication server which drops the session after a give-up for credit is reported (`WARN_REPLICATION_SERVER_BADLY_DISCONNECTED`), as with no stop at all: the give-up closes nothing. Only a close this server makes - by the watchdog of a stop, or by the heartbeat monitor - is not, as the close is marked begun before the sockets are closed. - A total update this domain is sending ends with an error once the stop gives up, as every message of it is refused - as it does on master as soon as a stop with the window open sets `shutdown`. Only the stop which waited for credit for good let it go on. - A session the watchdog or the heartbeat monitor closes ends with no `StopMsg`: the replication server sees the connection lost. - A reconnection of the listener to a better replication server (`reStart()` from `receive()`) still closes the current session with no error, and waits for a write blocked in it. - With the window open, a stop still waits for the end of a round #1204 holds the local changes back for, as #1202 records. ## Tests `ReplicationBrokerStopTest` (new) connects a domain to a fake replication server which keeps the session up and reads all of it, but opens a window of one update - or of the default 100000, in the cases of a write it does not read - and gives no credit after it unless the test asks it to. Like a replication server, it reports as its state the changes it received; it serves each connection on a thread of its own, and stops reading its sessions when the test asks it to. It sends the heartbeats the domain asks for, and can go away altogether: no reading, no heartbeat, no answer to a connection. It ends a session in order or drops it when the test asks it to. In the case of a connection under way, more replication servers take each connection and, once told to, answer nothing on it, as one which hangs does: a connection to each waits for the connection timeout in its SSL negotiation. Once the domain closed its session while the server read nothing, the server ends that session with a heartbeat, which the closed end answers with a reset: what the closed socket left queued, its end included, comes only once its TCP finds the window open again, and after a stall of 14 seconds its next window probe is 13 seconds away on Linux - the CI run of round 2 failed `aDisableEndsAWrite…` waiting for that end. A publish which waits for credit shows as a `WindowProbeMsg` on that server; "the stop waits for the lock" is the disabling thread `BLOCKED` in `PendingChanges.putReplicaOfflineMsg()`; a write it does not read is a writer which stays in `Session.send()` while the number of its changes stands still for two seconds in a row - half a second could be a pause of the collector, as a run after cases which allocate a lot showed; "reported as a close by the replication server" is a `WARN_REPLICATION_SERVER_BADLY_DISCONNECTED` in the error log; "the watchdog acted" is the end of its thread. The class runs with a connection timeout of 2 seconds instead of the 30 seconds `config-changes.ldif` gives the suite, and a case which needs another one sets it for its own run. | Case | What it pins | master | round 1 | |---|---|---|---| | `aDisableEndsWhenTheReplicationServerGivesNoCredit` | Once a change spent the window, disabling the domain ends, the replica offline message not written - no sooner than the connection timeout of 6 seconds the case sets, and within 22 seconds; the announcement of the message is withdrawn, and the session ends in order, with a `StopMsg`. | red | green | | `withNoConnectionTimeoutAStopWaitsForTheCredit` | With a connection timeout of 0, the replica offline message still waits for credit past its first wait and past 7 seconds, beyond the shipped default, and goes out once the credit comes. | green | green | | `aDisableEndsWhileALocalChangeWaitsForCredit` | Disabling while a second change waits for credit under the lock ends, and the change ends with it. | red | green | | `aStopPublishesWhatTheReplicationServerTakesInTime` | Credit given a second into the stop gets that change out, and then the replica offline message, last. | green | green | | `nothingIsWrittenAfterAChangeTheStopGaveUp` | A change given up is followed by nothing, even with credit offered right after it; a broker started again republishes it with the recovery of its session, and then writes again. | red | green | | `aSessionRestartRepublishesTheChangeTheStopGaveUp` | A restart of the session (`restartService()`) gives the change up as a disable does, and the recovery of the next session republishes it. Unlike `enable()`, which loads the state again and repairs it from `ds-sync-hist`, a restart keeps the state in memory: the change is found missing only because `ReplicationDomain.publish()` put it in the state although the broker did not write it. | red | green | | `aStopWritesNothingAfterAGiveUpEvenWithCredit` | A stop which gave a change up refuses the next one with the window open again, and an `EntryMsg` and a `DoneMsg`, which need no credit: no race with the 500 ms wait for credit. | red | green | | `aStopWhichOverlapsOneInProgressKeepsItsGiveUp` | After a change given up, the first stop writes nothing more, with the window open again, while a second stop runs beside it. | red | red | | `aBrokerStartedAgainWaitsForCredit` | A stop with the window open writes the replica offline message; a broker started again waits for credit past twice the connection timeout of that stop, and keeps its session. | green | green | | `aStopWhichCannotPublishTheOfflineMessageStillStopsTheBroker` | A stop whose replica offline message fails rethrows the failure and still stops the broker. | red | green | | `aDisableEndsAWriteToAReplicationServerWhichReadsNothing` | A local change blocked in its write under the lock of the pending changes, with credit to spare: disabling ends, no sooner than twice the connection timeout of 6 seconds and sooner than two and a half times it; nothing is written after the change, no session is opened again during the stop, nothing reports the close as one by the replication server, and the recovery of the session after `enable()` republishes the change. | red | red | | `aStopEndsItsOwnWriteAndOpensNoSessionAgain` | A write of the stop itself, with no lock of the pending changes in the way, ends the same way, no sooner than twice the connection timeout; the listener the close wakes waits for the end of the stop rather than connects, nothing reports the close as one by the replication server, and nothing is written after the write given up. | red | red | | `theHeartbeatMonitorEndsAWriteToAReplicationServerWhichIsGone` | A local change blocked in its write to a replication server which then reads nothing, sends no heartbeat and answers no connection: the write ends, no sooner than two heartbeat intervals, nothing reports the close as one by the replication server, and once the server is back the domain connects again and its change reaches it. | red | red | | `aConnectionUnderWayWhenTheStopGivesUpGetsNothingWritten` | The replication server ends the session in the stop, and four more which answer nothing hold the connection of the listener for the connection timeout each. The watchdog acts at its deadline with the connection still under way; an entry the stop publishes, which waits for the lock of that connection, is not written to the session the connection opens, and the stop ends once the connection does. | red | red | | `aDropByTheReplicationServerAfterAGiveUpIsStillReported` | After a change given up for credit, a replication server which drops the session with no `StopMsg` is reported, and the stop ends. | red | n/a | The fake replication server drops a session with `Session.abort()`, which round 1 does not have: `aDropBy…` cannot run there. On master its stop waits for credit and does not end. Round 2 (`26f7da4439`) fails `aDropBy…` and `aConnectionUnderWay…`, and round 3 (`4d1130e44e`) fails `aConnectionUnderWay…`, and only it. `SessionTest` gains three cases, red on master and on round 1, which have no `Session.abort()`; round 2 fails the third: | Case | What it pins | |---|---| | `anAbortEndsAWriteThePeerDoesNotReadAndACloseWhichWaitsForIt` | A write on the publishing thread, which a peer that reads nothing holds, and a close which waits for the lock of the writes behind it, both end with an abort; the peer finds the message cut short and the end of the stream, with no `StopMsg`. | | `anAbortEndsAWriteOfTheThreadOfTheSession` | A write of the thread of the session, which any close joins, ends with an abort, and the thread with it. | | `anAbortMarksTheCloseBegunBeforeItClosesTheSockets` | The socket of the session records, as it is closed, whether the close was marked begun by then: an abort marks it before it closes the sockets, so that the listener whose read their close ends finds it begun. | Mutants, each run against the classes of round 4 (`d3a6c33741`): | Mutant | Caught by | |---|---| | the stop not cleared by `start()` | `aBrokerStartedAgainWaitsForCredit`, `aDisableEndsAWrite…`, `aSessionRestart…`, `nothingIsWritten…` | | the stop recorded only after the replica offline message | `aConnectionUnderWay…`, `aDisableEndsAWrite…`, `aDisableEnds…NoCredit`, `aDisableEnds…WaitsForCredit`, `aDropBy…`, `aSessionRestart…`, `aStopEndsItsOwnWrite…`, `aStopWhichOverlaps…`, `aStopWritesNothing…`, `nothingIsWritten…` | | no deadline: give up at the first wait without credit | `aDisableEnds…NoCredit`, `aStopPublishes…`, `withNoConnectionTimeout…` | | give-up not kept for the later messages | `aStopWhichOverlaps…`, `aStopWritesNothing…`, `nothingIsWritten…` | | a stopping broker writes nothing at all | `aBrokerStartedAgainWaitsForCredit`, `aConnectionUnderWay…`, `aDisableEnds…NoCredit`, `aStopEndsItsOwnWrite…`, `aStopPublishes…`, `withNoConnectionTimeout…` | | only the replica offline message given up | `aDropBy…`, `aStopWhichOverlaps…`, `aStopWritesNothing…` | | no `finally` around the replica offline message | `aStopWhichCannotPublish…` | | a connection timeout of 0 gives up at the first wait (round 0) | `withNoConnectionTimeout…` | | a connection timeout of 0 read as the shipped 5 seconds | `withNoConnectionTimeout…` | | a constant deadline of 5 seconds instead of the connection timeout | `aConnectionUnderWay…`, `aDisableEndsAWrite…`, `aDisableEnds…NoCredit`, `withNoConnectionTimeout…` | | a constant deadline of 30 seconds | `aConnectionUnderWay…`, `aDisableEndsAWrite…`, `aDisableEnds…NoCredit`, `aDisableEnds…WaitsForCredit`, `aDropBy…`, `aSessionRestart…`, `aStopEndsItsOwnWrite…`, `aStopWhichOverlaps…`, `aStopWritesNothing…`, `nothingIsWritten…` | | `ReplicationDomain.publish()` puts in the state only what the broker wrote | `aSessionRestart…` | | an overlapping stop replaces the one in progress | `aStopWhichOverlaps…` | | the give-up of a publish reported as a message written | `aDisableEnds…NoCredit` (the announcement is left) | | after a give-up, only the updates refused | `aConnectionUnderWay…`, `aStopEndsItsOwnWrite…`, `aStopWritesNothing…` | | no watchdog (round 1) | `aConnectionUnderWay…`, `aDisableEndsAWrite…`, `aStopEndsItsOwnWrite…` | | the watchdog closes the session but gives nothing up | `aConnectionUnderWay…`, `aDisableEndsAWrite…`, `aStopEndsItsOwnWrite…` (a session opened again) | | the watchdog acts at the credit deadline | `aDisableEndsAWrite…`, `aDisableEnds…NoCredit` (no `StopMsg`), `aDropBy…`, `aStopEndsItsOwnWrite…`, `aStopWhichOverlaps…`, `aStopWritesNothing…` | | the watchdog acts whether the stop ended or not | `aBrokerStartedAgainWaitsForCredit` | | a watchdog with a connection timeout of 0 | `withNoConnectionTimeout…` | | the write deadline a constant 5 seconds past the credit deadline | `aConnectionUnderWay…` (the watchdog had not acted), `aDisableEndsAWrite…` | | the write deadline three times the connection timeout from the start of the stop | `aConnectionUnderWay…` (the watchdog had not acted), `aDisableEndsAWrite…` | | the write deadline two and a half times it | `aConnectionUnderWay…` (the watchdog had not acted), `aDisableEndsAWrite…` | | a session opened again once the stop gave up | `aDisableEndsAWrite…`, `aDropBy…`, `aStopEndsItsOwnWrite…` | | no read of the give-up under `connectPhaseLock` before the write, as round 3 did | `aConnectionUnderWay…` | | the listener reports no drop by the replication server once the stop gave up, as round 2 did | `aDropBy…` | | `Session.abort()` a plain close | `anAbortEndsAWriteThePeer…`, `anAbortEndsAWriteOfTheThread…`, `aDisableEndsAWrite…`, `aDropBy…`, `aStopEndsItsOwnWrite…`, `theHeartbeatMonitorEnds…` | | `Session.abort()` closes the session before the sockets | `anAbortEndsAWriteOfTheThreadOfTheSession` | | `Session.abort()` leaves the sockets to the close | both `SessionTest` cases of the abort | | `Session.abort()` leaves a close which had begun before to wait | `anAbortEndsAWriteThePeerDoesNotReadAndACloseWhichWaitsForIt` | | the heartbeat monitor closes a session whose heartbeats stopped as one with no error, as round 2 did | `theHeartbeatMonitorEnds…` | | `Session.abort()` closes the sockets before it marks the close begun, as round 2 did | `anAbortMarksTheCloseBegun…` | | no second read of the stop before its give-up is marked | not caught - see below | | a window probe written after the give-up | not caught - see below | Under the mutant which gives up only the replica offline message, a local change which waits for credit under the lock never gives up, and the watchdog ends it at twice the connection timeout: that is why that mutant no longer fails the cases where such a change holds the stop, as it did in round 1. The mutant "the state only what the broker wrote" survives a disable followed by `enable()`: `enable()` loads the state again, and `PersistentServerState.checkAndUpdateServerState()` repairs the CSNs of this replica from `ds-sync-hist`. A restart of the session keeps the state in memory, and only there does the mutant lose the change. Two races are not pinned, as a test cannot put a thread between the two steps without a hook: a give-up marked after `start()` cleared the stop it was read from - the mark lives in the object of that stop, and the second read of the stop narrows the window to that of master's loop head - and a watchdog acting after the end of its stop, which it does under the monitor `StopInProgress.end()` takes, under `startStopLock`. Nor is the start of the watchdog inside the `try`: a thread which fails to start needs a hook as well. The read of the give-up before a window probe is not pinned either: the probe is a few bytes which neither hold the stop nor tell the replication server anything of the changes, and the read only keeps `publish()` from writing anything after a give-up. ## Verification - `ReplicationBrokerStopTest` and `SessionTest` on their own at round 4 (`d3a6c33741`, on `616d5f6c17`), on macOS: 15/15 four times and 6/6 three times. Master fails 12 cases of the first - all but `aBrokerStartedAgain…`, `aStopPublishes…` and `withNoConnectionTimeout…` - and the 3 cases of the abort of the second. Round 1 (`8fb5973e30`) fails the 4 earlier cases the table marks red for it, `aConnectionUnderWay…`, and `aDropBy…`, which cannot run there, and the 3 cases of the abort. Round 2 (`26f7da4439`) fails `aConnectionUnderWay…`, `aDropBy…`, `theHeartbeatMonitorEnds…` and `anAbortMarks…`. Round 3 (`4d1130e44e`) fails `aConnectionUnderWay…` only. Every mutant above is caught, but the two noted; the runs a port collision with a test run of another worktree voided were run again. - On Linux in a container (JDK 21): `ReplicationBrokerStopTest` 15/15 twice, and `aConnectionUnderWay…`, `aDropBy…` and `aDisableEndsAWrite…` ten times more. - `ReplicationSynchronizationProviderConfiguration.xml` validates against `admin.xsd`. - Rebased onto `d954c4c53f` (#1204, #1211, #1219 and #1218 came in), at `276317c33a`, whose commit on top of round 4 changes a comment only; on macOS with JDK 21: `ReplicationBrokerStopTest` 15/15 and `SessionTest` 6/6, twice each, and the neighbours which share `publish()`: `ReplicationBrokerTest` (#1218) 37/37 and `HistoricalCsnOrderingTest` (#1204) 54/54.
…ead log file it is positioned in is rotated (#1228) Fixes #1227 ## Problem A replication server can skip one change of a replica for a directory server which is catching up, and the state of that directory server then moves past the change for good. This is the one-change loss of #1225, which `docker-convergence (combined)` showed again in run [39407529872](https://github.com/OpenIdentityPlatform/OpenDJ/actions/runs/39407529872) on a build with #1219. #1227 has the analysis of that run. A cursor on a changelog log reads the record it is positioned on in advance: 1. `LogFileCursor.positionTo()` keeps that record in `initialRecord` for the next `next()`, and leaves the file pointer after it. 2. When the head log file is rotated, `Log.rotateHeadLogFile()` moves every cursor open on it to the read-only file it was renamed to. It does so from the state `InternalLogCursor.getState()` saved: the file position and the current record, but not `initialRecord`. 3. A cursor positioned on the head log file and moved before it read anything therefore starts after the record it is positioned on. `FileReplicaDBCursor` takes the next one for the first change after its start CSN. `positionTo()` and the first `next()` take the shared lock of the log one after the other. So a rotation which asks for the exclusive lock in between runs before the read. Two roads go through that window: - `MessageHandler.fillLateQueue()` makes such cursors anew for every batch it reads. - `FileReplicaDBCursor.nextWhenCursorIsExhaustedOrNotCorrectlyPositionned()` positions again each time it looks for the changes written since. ## Fix - `Log.CursorState` keeps the record read in advance, and the state moved to the rotated file carries it. - `LogFile.LogFileCursor.getInitialRecord()` gives that record to the state. - `LogFile.getCursorInitialisedTo()` gives it back to the cursor made on the rotated file. ## Tests In `LogTest`, on its log which rotates after two records: - `aCursorReadsTheRecordItIsPositionedOnWhenTheHeadLogFileIsRotatedBeforeItsFirstRead`: a cursor positioned on the head log file reads that record first after a rotation. It covers every key matching and positioning strategy which reads a record in advance, 6 cases. - `aCursorPositionedAgainReadsTheRecordItIsPositionedOnWhenTheHeadLogFileIsRotatedBeforeItsNextRead`: the same for a cursor which read the newest record and is positioned again once more were written. This is the road `FileReplicaDBCursor` takes. ## Verification - **Without the fix:** 7 of the 95 tests of `LogTest` fail, each one on the skipped record (expected `key009`, `key010` or `key012`, read the record after it). - **With the fix:** 95 of 95 pass. - **Mutants:** each link of the fix was broken in turn, 5 mutants: - the state taken before the rotation drops the record; - the state moved to the rotated file drops it; - the cursor made from the state is not given it; - the cursor does not keep it; - the getter returns the current record instead. Each one fails all 7 new cases. - **Reactor** (`-Pprecommit verify -pl opendj-server-legacy -am`, JDK 27, with javadoc), `org/opends/server/replication/**` and `ChangelogBackendTestCase`: - 3967 tests, 2 failures. Both failures are the embedded server failing to start in `setUp` of `FileReplicaDBTest` and `ComputeBestServerTest`, on `0.0.0.0:65534: Address already in use`: another test server held the admin port. - Run again on their own, both classes pass (17 and 46 tests). - Every class of `replication/server/changelog/file` passes, and `ChangelogBackendTestCase` passes (31).
Fixes #1206
Fixes #1224
Fixes #1225
Problem
A replication server stopped sending a directory server the changes of another replica, and went on minutes further. The changes in between were in the changelog of every replication server, never reached that directory server, and
missing-changesstill reached 0.A handler which is behind reads the changes for its consumer from the changelog.
MessageHandler.getNextMessage()takes it back to its in-memory queue once the queue holds the first change of what it read, which is the oldest change the consumer lacks. Holding that change does not mean holding the ones after it:That change is then the oldest one the consumer lacks, and it is in the queue. The handler went back to the queue, the consumer was given the changes the queue still held of the other replica, and its state moved past the ones in between. The changelog is read again only for a change the state of the consumer does not cover (#963), so they were never sent.
A full queue keeps such a change because it is bounded in bytes as well and drops whole changes: the room a large change leaves takes a small one in.
The changelog is read without the lock of the queue, so a change arriving in between is lost the same way:
This fits what #1206 found:
#1225 is the same with a hole of one change, in the
combinedtopology of the convergence check. The replication server ofcv-c2never sent its directory server one delete ofcv-c1, though it sent the changes ofcv-c1on both sides of it and every add and delete of the other replicas. That is test 1 below: the queue dropped the change, and a change of another replica with an older CSN came in after it.Change
The handler records, for each replica, the newest change of it the queue may lack:
It goes back to the queue only once the state of the consumer covers them, and once the queue holds nothing before the first change read that the consumer lacks (
MsgQueue.holdsAChangeBefore()). It reads the changelog until then. All three ways back to the queue check what the queue lacks: the one which finds the first change read in the queue, the one taken when the read gave nothing, and the one taken when both queues are empty and nothing is found after the state of the consumer.StatusMachinelets neither status go to normal): after its import (ReplicationDomain,broker.reStart(false)), or once the changelog is cleared for its generation id, when the replication server closes its session (DataServerHandler.changeStatusForResetGenId()).Cookie based persistent searches (#1224)
FileChangelogDB.publishUpdateMsg()notified the cookie based persistent searches ofcn=changelogof every change it was given, also of one its replica DB did not take:Log.append()drops a record whose key is not after the newest one, which is what a change the changelog already holds is. A change a replication server receives twice reached those searches twice.LogFile.append(),Log.append()andFileReplicaDB.add()now tell whether the record was appended, andpublishUpdateMsg()notifies the persistent searches only of a change the replica DB took.Not addressed here, from the comment on #1206:
RemotePendingChanges.putRemoteUpdate()taking a change sent again which the state of the directory server covers.Tests
MessageHandlerCatchUpTest(new) drives a handler no domain feeds from one thread, as the writer of a session does, over the changelog of a real replication server. A change arrives the wayReplicationServerDomain.put()hands it over: written to the changelog, then given to the queue. For the reads, the domain of the handler can hand it a change right after it has read the changelog, or once a read gave nothing.aChangeTheFullQueueDroppedIsSentWhenAnOlderOneCameInAfterItaChangeWhichReachedTheDomainBeforeTheHandlerIsSentWhenAnOlderOneIsQueuedaChangeOfAReplicaTheQueueIsNeverHandedIsSentWhenAnOlderOneIsQueuedaChangeWhichArrivedWhileTheChangelogWasReadIsSentaChangeTheFullQueueDroppedWhileTheChangelogWasReadIsSentaHandlerWhoseQueueHoldsWhatTheConsumerLacksFollowsTheQueueAgainaDroppedChangeTheChangelogNoLongerHoldsDoesNotHoldTheHandlerBackaReplicaOfflineMessageDoesNotHoldTheHandlerBackaQueuedChangeTheChangelogNoLongerHoldsDoesNotHoldTheHandlerBackaDroppedReplicaOfflineMessageDoesNotHoldTheHandlerBackaHandlerWhichCaughtUpFromTheChangelogFollowsTheQueueAgainaHandlerWhichReadTheChangesOfAReplicaItIsNeverHandedFollowsTheQueueAgainFor #1224:
ChangelogBackendTestCase.aChangeTheChangelogAlreadyHoldsIsNotNotifiedAgainToACookieBasedPersistentSearchcn=changelogis notified of each once. Red on master:[csn1, csn1, csn2].LogTest.appendTellsWhetherTheRecordWasAppendedappend()says so.Mutants of
MessageHandlerandMsgQueue, each run againstMessageHandlerCatchUpTest:Mutants of the fix of #1224, each run against its two tests:
ChangelogBackendTestCaseChangelogBackendTestCaseLogTestVerification
Run directly with TestNG, JDK 21, macOS:
MessageHandlerCatchUpTest: 12 tests, 0 failures. On master 6 of them fail (1-5, 12); at the head of round 1,ad3e21ad6d, 1 fails (9).ChangelogBackendTestCasefails on master.In the reactor (
-Pprecommit verify -pl opendj-server-legacy -am, javadoc included), JDK 27, macOS,org/opends/server/replication/**andChangelogBackendTestCase: 3879 tests, 0 failures, 0 errors, 0 skipped. Among them isMissedUpdateRecoveryTest(#963), which drives the same handler through real sessions: 8 tests, 0 failures.