[#900] Time the ReplicaOfflineMsg grace period per replica, and spend it where the message can still be forwarded - #919
Conversation
…r replica, and spend it where the message can still be forwarded DSRSShutdownSync latched a single static timestamp on the first ReplicaOfflineMsg a process ever sent. A domain sends that message on every disableService() - an online import, a restore, a backend being disabled, a fractional or assured configuration change - so after the first one canShutdown() returned true at once for the rest of the process lifetime, and the shutdown the class exists for got no grace period at all. The other RSs were then never told the replica went offline, and their medium consistency point stayed pinned to its last CSN, so their change number index and external changelog stopped advancing for that domain. Keep the time of the message per domain and per replica. The collocated RS relays the message of every replica connected to it, so the forward of another replica's message says nothing about this one. The wait also had to move. ServerHandler.shutdown() deactivates the consumer, clears the message queue and closes the session before it joins the writer, so a writer looping on canShutdown() could no longer forward anything - and a ReplicaOfflineMsg is never written to the changelog, so the late queue cannot bring it back either. ReplicationServerDomain.shutdown() now waits before it stops its handlers, and only when an RS is connected to forward the message to; the loop in ServerWriter, which could only ever spin, is gone.
maximthomas
left a comment
There was a problem hiding this comment.
praise: The diagnosis is exactly right and the fix goes to the right depth.
- The root cause is named precisely:
compareAndSet(0, now)on a static field latches the window at the firstdisableService()— an import, a restore, a config change — not at the shutdown it exists for. - Deleting the
ServerWriterspin was necessary, not scope creep.ServerHandler.shutdown()sets the consumer inactive, clearsmsgQueueand closes the session before it joins the writer, andMessageHandler.getNextMessage()iswhile (activeConsumer)— so the old loop was a hot spin on an empty queue and a dead socket. The obvious per-baseDN-timestamp fix alone would have turned a silent no-op into a real 5 s busy spin at every shutdown. - Instance fields instead of statics also remove state bleed between several
ReplicationServers in one JVM. - The
wait/notifyAllis correct: theremovehappens-beforenotifyAll, the waiter re-checkscanShutdownunder the same monitor, so no wakeup can be lost; and thedeadlineclamp bounds the wait atgracePeriodeven if the domain keeps re-announcing itself offline. - Waiting before
assuredTimeoutTimer.cancel()is a deliberate, easily-missed detail, and getting it right matters. - Deferring #917 and #918 explicitly, rather than half-fixing them, is the right call.
issue (blocking): the wait is granted for a peer RS but released by any writer, including a directory server's.
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java:1680 gates on !connectedRSs.isEmpty(). The release does not:
// ServerWriter.java:112-117 — runs for DataServerHandler and ReplicationServerHandler alike
if (updateMsg instanceof ReplicaOfflineMsg)
{
dsrsShutdownSync.replicaOfflineMsgForwarded(
replicationServerDomain.getBaseDN(), updateMsg.getCSN().getServerId());
}put() fans a DS-sourced ReplicaOfflineMsg out to connectedDSs as well as connectedRSs, and isUpdateMsgFiltered() only drops it for a DS in BAD_GEN_ID_STATUS/FULL_UPDATE. Peer RS blocked in session.publish on a backlogged link + a local DS with an empty queue ⇒ the DS writer clears the key first, stopAllServers(true) then discards the peer RS's queued message. Zero peer RSs told — the #900 outcome, on a shutdown that paid for the grace period. put()'s RS-before-DS enqueue order buys microseconds; the writers are independent threads.
if (updateMsg instanceof ReplicaOfflineMsg && !handler.isDataServer())Same root cause as #917, different consequence (#917 = a second RS unaware; this = every RS unaware), and a one-liner — worth taking here.
Two comments currently record the opposite and should go with it:
ReplicationServerShutdownSyncTest.java:153-157— "a ReplicaOfflineMsg is never sent to a directory server anyway" is false.ReplicationServerDomain.java:1669— "forwarded to the other RSs" promises all-of-N; the code delivers one-of-N.
issue (blocking): no test pins the ordering the PR exists for.
opendj-server-legacy/src/test/java/org/opends/server/replication/server/ReplicationServerShutdownSyncTest.java:228-247 registers a peer RS by field assignment and never runs finalizeStart() — where the handshake creates the writer, the reader and the sendWindow. The registered peer has a null writer. No test constructs a ReplicaOfflineMsg; remoteEnd is only closed, never read.
Both waiting tests assert elapsed time only:
assertThat(elapsed).isGreaterThanOrEqualTo(DSRSShutdownSync.REPLICA_OFFLINE_GRACE_PERIOD);Move awaitReplicaOfflineMsgForwarded to the end of ReplicationServerDomain.shutdown(), after stopAllServers(true) — fully reintroducing OPENDJ-1453 — and every assertion stays green. The suite pins duration, never delivery, which is why the two issues above are invisible to CI.
Wanted: one test that registers a peer RS through the real handshake, publishes a genuine ReplicaOfflineMsg, and asserts it reaches the remote end before shutdown() returns. It would fail today (see next). Minimum: assert ordering — the peer handler still registered, its consumer still active, when the wait returns.
note: "Each of them was seen failing on the unfixed code first" doesn't hold as written — neither class compiles against the base (no DSRSShutdownSync(long), no REPLICA_OFFLINE_GRACE_PERIOD, one-arg signatures), and two of the five RS tests pass on base behaviour, since base shutdown() has no wait and these fixtures have no writer to spin. Name the adapted variants or drop the sentence.
issue (non-blocking): the wait is released by enqueue, not by transmission.
ServerWriter calls replicaOfflineMsgForwarded right after session.publish(). For a peer-RS handler that never touches the socket — ServerHandler.finalizeStart() runs session.start() + waitForStartup() before writer.start(), so isRunning is always true:
// Session.java:316-326 -> LinkedBlockingQueue<>(4000) at :99
if (isRunning.get()) {
while (!closeInitiated) {
if (sendQueue.offer(buffer, 100, MILLISECONDS)) return; // queued, not sent
}
}// Session.java:151-166 — no drain, no flush
closeInitiated = true; interrupt(); join(); /* then close sockets */So the wait returns, session.close() interrupts the publisher out of sendQueue.take(), and the buffer dies with the socket. It is never in the changelog, so nothing re-delivers it. With a backpressured peer up to 4000 buffers can sit ahead of it — the window is bounded by TCP, not by scheduling. That is precisely the case a grace period is for; a healthy peer never needed one.
Still strictly better than master, which loses the message one step earlier in msgQueue. But the PR text's "spend it where the message can still be forwarded" should be qualified: the wait proves handed to the session, not written to the wire. Fix properly by releasing on drain, or file it beside #917/#918.
suggestion (non-blocking): cap the grace period per shutdown, not per domain.
// ReplicationServer.shutdown()
for (ReplicationServerDomain domain : getReplicationServerDomains()) { domain.shutdown(); }Sequential, and each awaitReplicaOfflineMsgForwarded takes its own deadline = now + gracePeriod. The old static timestamp meant later domains never waited; that accident is gone, so N base DNs now cost up to N × 5 s. Two paths pay it in full because nothing in the JVM can release them:
- the collocated DS picked a remote RS — the local RS sees the message only relayed, and
put()skips theconnectedRSspush for an RS-sourced message. Reachable becausecomputeBestReplicationServerappliesfilterServersOnSameHostlast, after the group-id / generation-id filters. ReplicationServerDomain.java:546— aChangelogExceptioncallslocalReplicationServer.shutdown()on theServerReaderthread;put()already returned at:339before any handler push, so that message can never be forwarded. Delay only, not a deadlock.
One shared deadline computed once in ReplicationServer.shutdown() restores the 5 s ceiling.
(The wait is the remainder of 5 s from the send, not a fresh 5 s — a slow DS shutdown absorbs it.)
… forward it is waiting for Review of OpenIdentityPlatform#919. * The wait was ended by any writer publishing a ReplicaOfflineMsg, a directory server's included. ReplicationServerDomain.put() never queues one for a directory server, yet a directory server which is catching up reads its updates from the changelog, where ReplicaCursor synthesizes one from the offline CSN of the replica. ServerWriter now releases the wait only for a peer replication server. * A replica announces itself offline on every disableService(), so the message forwarded now may be an older one, queued behind a backlog since an earlier import. The pending entry is keyed by the CSN of the announcement and a forward older than it no longer consumes its grace period: PendingChanges.putReplicaOfflineMsg() hands that CSN back for it. * All the domains of a shutdown wait together, before any of them is stopped, instead of one after the other: the grace period bounds the whole shutdown, and the wait of one domain no longer spends the grace period of the next. The wait runs on System.nanoTime(), so a backwards clock step cannot stretch it, and the path which aborts a failed initialization does not wait at all - nothing in an instance which never came up can forward a message. * Two comments recorded the opposite of what the code does and were corrected: the message can reach the writer serving a directory server, and the wait ends at the first forward, not once every peer has it (OpenIdentityPlatform#917). The tests pin delivery and ordering, not only duration: a peer connected through the real handshake must have received the message before shutdown() returns, a message published to a directory server must not end the wait, a stale forward must not release a newer announcement, and one wait must cover every domain of the shutdown. Each of them was run against the behaviour it replaces and fails there.
|
All four points are addressed in 1b0a3c7, and the description is rewritten to match. issue (blocking): the wait is granted for a peer RS but released by any writer. Taken - with a correction to the mechanism, because the fix has to be justified by the path that
// Replica offline messages should not get to a connected DS, they are meant to be
// exchanged only between RSes
return updateMsg instanceof ReplicaOfflineMsg;The method quoted in the review is the one in The conclusion holds through another door. A directory server which is catching up fills its late The two comments went with it, one of them differently than asked: issue (blocking): no test pins the ordering the PR exists for. Taken.
The "seen failing on the unfixed code first" sentence is gone from the description. What replaces issue (non-blocking): the wait is released by enqueue, not by transmission. Correct, and now recorded as a limitation in the description instead of being implied away: the suggestion (non-blocking): cap the grace period per shutdown, not per domain. Taken, and one step further than a shared deadline. With the domains waiting one after the other, That also removed the deadline parameter from the public Two more things came out of the same pass, both in the commit:
|
Fixes #900
The bug
DSRSShutdownSync.stopInstanceTimestampwas a staticAtomicLongwritten once per JVM withcompareAndSet(0, now). A domain publishes aReplicaOfflineMsgon everydisableService(),not only at shutdown - an online import or a restore (
MultimasterReplication.processImportBegin/
processRestoreBegin), a backend being disabled(
LDAPReplicationDomain.performBackendPreFinalizationProcessing), a fractional or assuredconfiguration change - so the first of those latched the timestamp and
canShutdown()returnedtrueat once for the rest of the process lifetime. At a later, real shutdown the grace periodOPENDJ-1453 added was already spent.
The message not being forwarded costs more than the notification:
ChangeNumberIndexerexcludes offline replicas from the medium consistency point
(
replicaOffline()/getOldestLastAliveCSN()), so a replication server which never learns thereplica went offline keeps that point pinned to its last CSN, and its change number index and
external changelog stop advancing for the domain.
The fix
Per domain, per replica, per announcement.
replicaOfflineMsgsnow mapsbaseDN -> serverId -> the announcement still to be forwarded, and that entry carries the CSNof the message. Per domain because the message is sent on every
disableService(); per replicabecause the collocated RS relays the message of every replica connected to it, so the forward of
another replica's message says nothing about this one; per announcement because the message of
an earlier
disableService()may still be queued behind a backlog, and forwarding it must notspend the grace period of the one the shutdown is waiting for.
PendingChanges.putReplicaOfflineMsg()hands back the CSN it generated, so the domain recordswhat it actually announced. The fields are no longer static: the collocated DS and RS sides
coordinate through the single instance
MultimasterReplicationhands to both.Wait where the forward is still possible.
ServerHandler.shutdown()sets the consumerinactive, clears the message queue (
MessageHandler.shutdown()) and closes the sessionbefore it joins the writer, so the loop on
canShutdown()inServerWritercould notforward anything by the time it ran - and a
ReplicaOfflineMsgis never written to thechangelog by the RS (
publishUpdateMsg()only callsnotifyReplicaOffline()), so the latequeue cannot bring it back either.
ReplicationServer.shutdown()now waits before it stops anyof its domains, and the spin in
ServerWriteris gone.Only a peer RS ends the wait.
ReplicationServerDomain.put()never queues aReplicaOfflineMsgfor a directory server - itsisUpdateMsgFiltered()drops it there - but adirectory server which is catching up fills its late queue from the changelog, where
ReplicaCursorsynthesizes one from the offline CSN of the replica. Publishing that saysnothing about the peer replication servers, so
ServerWriterreports the forward only for apeer RS.
Only when it can pay off, and once for the whole shutdown. The wait is skipped when no RS
is connected: there would be nobody to forward the message to, and a standalone server would
pay the whole grace period at every shutdown. All the domains of one shutdown wait together and
share one deadline, so N base DNs cost one grace period rather than N, and the wait of one
domain does not spend the window of the next. It runs before the assured timer of every domain
is cancelled, so an assured update still waiting for acks keeps timing out during it. It is
measured on
System.nanoTime(), so a backwards clock step cannot stretch it, andabortInitialization()does not wait at all: nothing in an instance which never came up canforward a message.
Tests
DSRSShutdownSyncTest(12) - the grace period is not spent by an earlier message, is countedper domain and per replica, neither the forward of another replica's message nor that of an
older announcement ends the wait, one wait covers every domain of a shutdown, and the deadline
bounds it.
ReplicationServerShutdownSyncTest(8) - a peer connected through the real handshake hasreceived a genuine
ReplicaOfflineMsgby the timeshutdown()returns; a message published toa directory server does not end the wait; the shutdown is not delayed when no peer RS is
connected, when only a directory server is, or when no message is pending; and the whole
shutdown stays within one grace period while two domains keep announcing themselves offline.
Every guard was checked by reverting it and observing that exactly the test which names it fails:
the writer guard (
theForwardToADirectoryServerDoesNotEndTheWait, 256 ms instead of the graceperiod), the CSN of the announcement (
aStaleForwardDoesNotConsumeTheGracePeriodOfANewerMessage),the shared deadline (
theGracePeriodIsSharedByAllTheDomainsOfOneShutdownandaSharedDeadline.../oneWaitCoversEveryDomainOfTheShutdown), and the wait running before thehandlers are stopped (
thePeerReceivesTheReplicaOfflineMsgBeforeTheShutdownReturns, which is theonly one of them a duration-only suite would not have caught). The two classes take about a
minute together, three of their tests spending the production grace period on purpose.
Regression:
ReplicationServerTest,ReplicationServerDynamicConfTest,ReplicationDomainTest,ReSyncTest,SchemaReplicationTest,ChangeNumberControlPluginTestCase,MonitorTest,HandshakeAbortRegistrationTest,HandshakeAbortGenerationIdTest.Left out
Limitations of the same mechanism, found while fixing this one and left to their own issues:
covers the window in which
publishReplicaOfflineMsg()records the announcement only afterPendingChangeshas already published it: a forward which wins that race finds nothing toclear, and the entry recorded afterwards holds the shutdown for its whole grace period.
Session, not that it reached the wire:Session.publish()enqueues the buffer for the session's own publisher thread, andSession.close()interrupts that thread without draining the queue. Fixing it belongs inSession- drain before interrupting, or report the forward from the publisher thread. It isstill strictly better than master, which loses the message one step earlier, in
msgQueue.