Repository navigation
[#917] Wait for every peer replication server to forward the ReplicaOfflineMsg - #947
Conversation
…forward the ReplicaOfflineMsg DSRSShutdownSync kept one pending entry per domain and replica, and the first ServerWriter to publish the message cleared it for everybody. ReplicationServerDomain.put() queues the message for every connected peer RS and each of them is served by its own writer, so the faster one released the wait and stopAllServers() cleared the queue and closed the session of the other before its writer got there. That peer never learned the replica went offline, and its ChangeNumberIndexer keeps the medium consistency point pinned to the last CSN of that replica - a point computed across all ECL enabled domains, so its change number index and external changelog stop advancing for every one of them until the replica writes again. The pending entry now knows which replication servers the message was queued for and is released only once all of them have forwarded it: * ReplicationServerDomain.put() records the recipients before it queues the message for any of them: a peer can forward it as soon as it is in its queue, and a forward reported before the recipients are known would end the wait at once. A peer the message is not queued for - none connected, a different generation id - is not waited for, and neither is one which connected after it was queued. * ServerWriter reports which peer forwarded the message. * A peer which will not forward what it was given is dropped from what is awaited: one which stops during the grace period, in stopServer() and unregisterFailedHandshake(); one whose teardown had already started when the recipients were recorded; and one whose writer drops the message on the way out, since the filter in ServerWriter is wider than the one put() applies when it queues the message. * With no recipient recorded - the message of a replica which picked a remote replication server, or an announcement recorded after its message was already relayed (OpenIdentityPlatform#918) - the wait keeps the behaviour it had: the first forward ends it. Tests: * DSRSShutdownSyncTest (9 new) - the forward of one peer does not end the wait of the others and the last one does; a message queued for nobody waits for nothing; a peer which stops is no longer waited for but does not release the others; the forward of a peer the message was not queued for changes nothing; the recipients of an earlier announcement are not taken for those of this one; and the wait is woken up by the peer which goes away. * ReplicationServerShutdownSyncTest (2 new) - two peers connected through the real handshake, the second one held back by a full send window, have both received the message by the time shutdown() returns; and the shutdown stops waiting for a peer whose message its own writer dropped. Both were seen failing with the guard they name reverted, on the message and not on the duration. Regression: ReplicationServerTest, HandshakeAbortRegistrationTest, HandshakeAbortGenerationIdTest, ReplicationDomainTest, MonitorTest, ChangelogBackendTestCase, FileChangelogDBTest, ChangeNumberControlPluginTestCase.
maximthomas
left a comment
There was a problem hiding this comment.
praise: The barrier now has the same granularity as the fan-out it guards, and the details that make
that work are all present.
- Recipients are recorded before anything is queued, so a fast writer cannot report a forward for a
recipient set that does not exist yet.put()keeps its original allocation-free loop for every other
message type, so the hot path pays nothing. - The forward carries the forwarder's id (
replicaOfflineMsgForwarded(baseDN, csn, serverId)), and the
give-up is reported from every place a peer can stop being able to forward — including the
writer-side filter, which is genuinely wider than the oneput()applied
(isDifferentGenerationIdisgenerationId > 0 && generationId != g; the writer also drops on-1). replicaOfflineMsgDispatchedgates onpending.csn.equals(offlineCSN), so the recipients of an
earlier announcement cannot be attached to this one — the same care#919took on the forward path.- The
awaitedForwarders == nullfallback keeps the pre-PR behaviour where no recipient was recorded
(relay, the#918ordering half). That is the right trade: "no worse than master" instead of a
guaranteed 5 s stall on a path the fix does not own. - An empty recipient list clears the entry immediately, which is strictly sharper than the caller-side
getConnectedRSs().isEmpty()check it supersedes. theShutdownWaitsForEveryPeerToBeToldTheReplicaWentOfflinegoes through the real handshake with a
peer advertising a send window of one, so its writer really blocks on the permit. It asserts on the
message arriving at both peers, not on a duration. That is the test this bug needed.- The DS/RS asymmetry is applied consistently at all four new call sites, and the shared shutdown
deadline from#919is untouched — the whole shutdown is still bounded by one grace period.
issue (blocking): unregisterFailedHandshake reports its give-up before the recipients exist, and
shuttingDown() cannot see that teardown — the recipient stays awaited forever.
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java:429
engageShutdown() has exactly one caller in the tree — stopServer() at :1119. ServerHandler.shutdown()
never sets the flag, so shuttingDown() is false for every aborted handshake. And the give-up on that path
runs while nothing is recorded yet, where giveUpOn is a deliberate no-op:
// unregisterFailedHandshake
connectedServers.remove(sHandler.getServerId(), sHandler);
if (!isDataServer) { noLongerAwaitTheForwardOf(sHandler); } // no-op: awaitedForwarders is still null
sHandler.shutdown(); // clears the queue, no writer was started// pushReplicaOfflineMsgToReplicationServers
localReplicationServer.getDSRSShutdownSync()
.replicaOfflineMsgDispatched(baseDN, offlineMsg.getCSN(), recipientIds); // records it AFTER the give-up
for (ReplicationServerHandler rsHandler : recipients)
{
if (rsHandler.shuttingDown()) { noLongerAwaitTheForwardOf(rsHandler); } // false for an aborted handshake
else { addUpdate(rsHandler, offlineMsg, notAssuredUpdateMsg, assuredServers); }
}The window is not exotic: ReplicationServer.shutdown() interrupts listenThread at :1189, before
awaitReplicaOfflineMsgsForwarded() at :1203. The handshake runs inline on that thread and blocks in
session.waitForStartup() before reader/writer.start(), so the interrupt becomes
abortStart → unregisterFailedHandshake. put() holds no domain lock, so it runs concurrently.
Result: the id is recorded, addUpdate goes into a queue that was just cleared, and nobody can ever report
that id — the handler is out of connectedRSs, so no stopServer will run for it. The wait burns the full
REPLICA_OFFLINE_GRACE_PERIOD on every shutdown that races an aborted RS handshake with at least one other
peer connected. Cost is the stall, not a lost message — that peer's session was already closed.
The PR body lists unregisterFailedHandshake() among the covered cases, so this is also a claim/code gap.
Suggested fix: call engageShutdown() on that path too, or re-check connectedRSs membership after
replicaOfflineMsgDispatched and strike off whoever left in between.
todo (blocking): three of the four give-up call sites have no test.
opendj-server-legacy/src/test/java/org/opends/server/replication/server/ReplicationServerShutdownSyncTest.java
Neither test file mentions shuttingDown, stopServer, unregisterFailedHandshake or abortStart. Only the
ServerWriter filtered branch is exercised at server level. Deleting noLongerAwaitTheForwardOf from
stopServer (:1157) or from unregisterFailedHandshake (:1263), or deleting the shuttingDown() branch
(:429-432), is caught by nothing.
The DSRSShutdownSyncTest cases that look like coverage — aPeerWhichStoppedIsNoLongerWaitedFor,
aPeerWhichStoppedDoesNotEndTheWaitOfTheOthers, theWaitEndsWhenTheLastPeerExpectedToForwardStops — call
replicaOfflineMsgNotForwarded directly. They pin the sync object, never the wiring.
This is why the issue above went unnoticed. One server test that disconnects a peer during the grace period
and one that aborts a handshake would cover both.
issue (non-blocking): the two-peer test asserts wire receipt after shutdown() closed the sessions.
opendj-server-legacy/src/test/java/org/opends/server/replication/server/ReplicationServerShutdownSyncTest.java:342
final long startTime = System.nanoTime();
windowOpener.start();
replicationServer.shutdown(); // stopAllServers -> MessageHandler.shutdown -> session.close()
...
assertThat(receivedWhenHeldBack.get(SOCKET_TIMEOUT_MS, TimeUnit.MILLISECONDS))
.as("... the peer which was held back never learned that the replica went offline ...")
.isNotNull();The barrier is released by session.publish(), which enqueues into a 4000-slot sendQueue; Session.close()
discards it without draining — the #919 limitation this PR restates in "Left out". A scheduling window
between publish() returning and the publisher thread draining makes the future null, and the test then
fails with a message pointing at the bug being fixed rather than at the race.
Asserting on the forward being reported (barrier state) rather than on bytes reaching the peer would remove
the ambiguity.
issue (non-blocking): the PR body overstates the second server test's evidence.
opendj-server-legacy/src/test/java/org/opends/server/replication/server/ReplicationServerShutdownSyncTest.java:404
The body says "Both new server tests were run with the guard they name reverted and fail on the message, not
on a duration". That holds for the first test. theShutdownStopsWaitingForAPeerWhoseMessageTheWriterDropped
has exactly one assertion, and it is a duration whose .as() merely reads like a message:
assertThat(elapsed)
.as("the shutdown kept waiting for a forward its own writer had already dropped")
.isLessThan(DSRSShutdownSync.REPLICA_OFFLINE_GRACE_PERIOD);It also bypasses the production path it is named after — replicaOfflineMsgDispatched is called by hand and
the message is enqueued with rsHandler.add(...), never entering put(). And the mutation margin is thin:
reverting the ServerWriter branch gives elapsed ≈ 5000 − t_setup + t_teardown, killed only because the
post-wait teardown costs more than the two setup calls.
Worth correcting the body and adding a state assertion.
todo (non-blocking): the giveUpOn null-branch asymmetry is untested, and the mutation that undoes it
survives.
opendj-server-legacy/src/main/java/org/opends/server/replication/service/DSRSShutdownSync.java:404
private boolean giveUpOn(int replicationServerId)
{
final Set<Integer> awaited = awaitedForwarders;
return awaited != null && awaited.remove(replicationServerId) && awaited.isEmpty();
}The awaited != null conjunct is load-bearing and deliberate — a disconnect says nothing about a message the
peer was never given, which is exactly the opposite of forwardedBy's fallback. Every test calling
replicaOfflineMsgNotForwarded first calls replicaOfflineMsgDispatched for the same base DN, so this branch
is never entered. Dropping the conjunct turns any peer disconnect into an immediate release of a not-yet-
dispatched message — the master bug in a new shape — and the suite stays green.
A four-line unit test closes it: replicaOfflineMsgSent, then replicaOfflineMsgNotForwarded with no
dispatch, assert the message is still pending.
todo (non-blocking): the capability claimed over the superseded guard has no test.
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java:406
The body claims a peer with a different generation id is no longer waited for, "which the caller side check
on getConnectedRSs().isEmpty() did not cover". No test drives a connected peer with a mismatched
generation id through put(). canShutdownWhenTheMessageWasQueuedForNoPeer passes an empty list straight to
the sync object, and shutdownIsNotDelayedWhenNoOtherReplicationServerCanForwardTheMessage is still released
by the unchanged ReplicationServer:1235 guard — so it would pass with the new filter removed.
suggestion (non-blocking): move the give-up in stopServer into a finally.
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java:1130
if (!sHandler.engageShutdown()) { ... } // one-shot: no later stopServer re-enters
try { lock(); } // lock.lockInterruptibly()
catch (InterruptedException ex) { Thread.currentThread().interrupt(); return; } // skips :1156-1157The peer is then never struck off and nothing can retry. The residual is narrow — the early return also skips
unregisterServerHandler, so the queue is not cleared and a live writer can still publish and report; a real
stall needs the caller to be the dying writer/reader itself. No in-tree interrupter of those threads was found
inside the grace window, so this is a hardening item rather than a live defect.
nitpick (non-blocking): the new comment promises an ordering the code does not enforce.
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServerDomain.java:1155
// after the unregistration, which must not be skipped if this throws
unregisterServerHandler(sHandler, shutdown, false);
noLongerAwaitTheForwardOf(sHandler);Two plain statements inside a try whose only handler is a method-scope catch (Exception) and whose
finally releases the lock only — a throw at the first line does skip the second. No realistic throw site
exists inside unregisterServerHandler, so either a try/finally or a reworded comment.
suggestion (non-blocking): retire what the new mechanism replaced.
opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServer.java:1235
if (!domain.getConnectedRSs().isEmpty()) { domainsToWaitFor.add(domain.getBaseDN()); }The body says the recipient set supersedes this check, but it is still there and sampled once before the wait
— a second, weaker source of truth (a peer that connects after the message was queued owes nothing, yet still
makes the domain eligible). Two related leftovers:
DSRSShutdownSync.java:56still opens with "The ReplicaOfflineMsg which has not been forwarded yet, per
domain and per replica"; an entry now legitimately holds a message that has been forwarded, by some of
its recipients.- 7 of the 9 new unit tests assert through
canShutdown(DN), which has no production caller at HEAD. Not
introduced here, but it is where most of the new test evidence rests.
…eardown against the dispatch unregisterFailedHandshake() reported its give-up while nothing was recorded yet - put() records the recipients of a message and only then queues it - and it left the handler's shutdown flag alone, so the loop which reads that flag queued the message for a handler whose queue had just been cleared. The reader and writer threads whose death reaches stopServer() are never started for an aborted handshake, so nothing else would ever strike that peer off and the shutdown waited out its whole grace period. Every teardown now raises the flag before it gives up, and put() reads it after it has recorded the recipients: of the two, at least one always sees the other. * unregisterFailedHandshake() calls engageShutdown() before noLongerAwaitTheForwardOf(). * stopServer() gives up in a finally of its own, so an interrupted lock acquisition or an unregistration which throws cannot skip it - engageShutdown() is one-shot and no later call retries. The comment which claimed that ordering without enforcing it is gone. * The javadoc of awaitReplicaOfflineMsgsForwarded() says what the getConnectedRSs() check still covers - an announcement which never went through this RS, for which no recipient can be recorded - rather than leaving it looking superseded. * The javadoc of DSRSShutdownSync.replicaOfflineMsgs says an entry is owed a forward rather than not forwarded yet: it legitimately holds a message some recipients have already sent. Tests: * ReplicationServerShutdownSyncTest (3 new, 2 reworked) - all of them drive put() through a real broker and assert on the barrier through a DSRSShutdownSync which records what the production code reports to it: the handshake aborted inside the dispatch, the peer which disconnects, the peer whose generation id differs, the writer which drops, and both forwards of the two-peer test. * DSRSShutdownSyncTest (1 new) - a peer which stops before the message is queued does not end the wait. Six mutations watched, one at a time, each producing exactly the failure named and no others.
|
@maximthomas All eight points are addressed in issue (blocking):
|
| mutation | fails with |
|---|---|
engageShutdown() out of unregisterFailedHandshake() |
the shutdown waited for a peer whose handshake had been aborted, and which nothing else will ever strike off |
the give-up out of the finally of stopServer() |
the shutdown kept waiting for a forward from a peer which had disconnected |
| the generation id filter out of the recipient list | the message was recorded as queued for a peer which does not share the generation id of the domain |
the give-up branch out of ServerWriter |
the writer dropped the message without telling the shutdown to stop waiting for the peer it was queued for |
awaited != null out of giveUpOn() |
aPeerStoppingBeforeTheMessageIsQueuedDoesNotEndTheWait |
forwardedBy() releasing on the first forward |
the wait ended on the first peer forwarding the message, without the writer of the peer which was held back ever reporting one |
Each produced exactly the failures listed and no others.
issue (non-blocking): the two-peer test asserts wire receipt after shutdown() closed the sessions
The barrier assertion is in, and first:
assertThat(shutdownSync.forwardedBy())
.as("the wait ended on the first peer forwarding the message, without the writer of "
+ "the peer which was held back ever reporting one")
.contains(REMOTE_RS_ID, HELD_BACK_RS_ID);I kept the wire assertion rather than replacing it - it is the outcome #917 is about, and it is what caught the bug in the first place - but it is now last, and its message says that the forward above it was already asserted. A run where only that one fails is the Session.close() window of #919 and nothing else, which is exactly the ambiguity you were pointing at. Verified: with forwardedBy() releasing on the first forward, the test fails on the barrier assertion at line 363, not on the read.
issue (non-blocking): the PR body overstates the second server test's evidence
Both halves right, and both fixed rather than just the sentence.
The test goes through put() now. A peer whose send window is one is filled with a change, so the ReplicaOfflineMsg published by the broker is queued for it by put() and taken off the queue by its writer, which then blocks on the permit - and ServerHandler.take() acquires that permit before isUpdateMsgFiltered() is reached, so the generation id can be changed while the message is already in flight and the drop is deterministic rather than raced. The assertion is the give-up itself, waited for with a bound well short of the grace period, so a writer which never reports is named as such instead of appearing as a shutdown which waited.
The .as()-that-reads-like-a-message is gone with the duration it decorated; the body says what the test now measures.
issue (non-blocking): the giveUpOn null-branch asymmetry is untested
Your four lines, with the reason in the javadoc:
shutdownSync.replicaOfflineMsgSent(baseDN1, newCSN(SERVER_ID));
shutdownSync.replicaOfflineMsgNotForwarded(baseDN1, RS_ID);
assertThat(shutdownSync.canShutdown(baseDN1)).isFalse();Dropping the conjunct fails it, and nothing else.
todo (non-blocking): the capability claimed over the superseded guard has no test
theMessageIsNotQueuedForAPeerWhoseGenerationIdDiffers drives a connected peer with a mismatched generation id through put() and asserts the recipients are empty - the claim itself, rather than a duration which the writer side give-up would satisfy anyway a moment later.
suggestion (non-blocking): move the give-up in stopServer into a finally
Done, and covering the interrupted lock acquisition as well:
finally
{
/*
* On every exit of this block, including the interrupted lock acquisition above and an
* unregistration which threw: the flag engageShutdown() has just set is one-shot, so no
* later stopServer() will run for this handler and nothing else would strike it off.
* ...
*/
if (sHandler.isReplicationServer())
{
noLongerAwaitTheForwardOf(sHandler);
}
}I hesitated over the interrupt path, since giving up on a peer whose writer might still publish is the wrong direction. It is not that case: every caller of stopServer(h, false) closes the session before it gets here - ServerReader and ServerWriter both do it in their own finally - or is stopping the handler on purpose. So by the time this runs the peer cannot be told anything, whatever the method managed to do. What is still left behind on that path is the handler itself, registered with its queue uncleared; that leak predates this PR and is now named in "Left out".
nitpick (non-blocking): the new comment promises an ordering the code does not enforce
The two statements are no longer two statements - the give-up moved to the finally above, so the comment which claimed the ordering is gone and the structure states it.
suggestion (non-blocking): retire what the new mechanism replaced
Here I disagree with the conclusion, though not with the reading, and I corrected the body instead of removing the check.
getConnectedRSs().isEmpty() is not superseded, because it does not overlap with the recipient set on the case it exists for. An announcement which never went through this replication server at all - a replica which picked a remote RS, the #918 ordering - has awaitedForwarders == null, and remainingGracePeriod() reads only sentTime: nothing can clear such an entry except a forward or the grace period itself. With no peer connected, no forward will ever be reported, so dropping the check would make every such shutdown pay the full 5 s. The recipient set is sharper wherever a dispatch did happen, and the check is the only bound wherever one did not.
You are right that it is a second, weaker source of truth for the domains where both apply. That is now written where it is read, in the javadoc of awaitReplicaOfflineMsgsForwarded(), and the body no longer claims the recipient set supersedes it.
The two related leftovers:
DSRSShutdownSync:56corrected - an entry is owed a forward rather than not forwarded yet, and it says an entry legitimately holds a message some of its recipients have already sent.canShutdown(DN)having no production caller since The ReplicaOfflineMsg grace period is spent on the first message of the process: DSRSShutdownSync latches its timestamp and never resets it #900 is right. The four new server tests go throughReplicationServer.shutdown()rather than through it, so the new evidence does not rest there; retiring the method would touch the seven unit tests which do, and belongs on its own.
Verification
mvn -Pprecommit -pl opendj-server-legacy verify over the classes below, no failures:
DSRSShutdownSyncTest22,ReplicationServerShutdownSyncTest13ReplicationServerTest7,ReplicationDomainTest12,MonitorTest1,GenerationIdTest4,TopologyViewTest1,ReplicationServerFailoverTest2,StateMachineTest5,ReplicationServerDynamicConfTest9,ChangelogBackendTestCase30,FileChangelogDBTest8,ChangeNumberControlPluginTestCase4HandshakeAbortRegistrationTest3 andHandshakeAbortGenerationIdTest2 on their own - in a long run they collide on the administration connector port, which is the harness rather than this change.
…r before the shutdown can abort it ReplicationServerShutdownSyncTest waited for the peer to be in connectedRSs and then triggered the shutdown, but startFromRemoteRS() registers the handler before finalizeStart(), which is what starts its reader and writer, and the whole handshake runs in the listen thread. ReplicationServer.shutdown() interrupts that thread before it waits for the ReplicaOfflineMsgs, so a shutdown triggered in that window aborts the handshake in Session.waitForStartup(), closes the session and unregisters the handler: the peer is gone before the message can be queued for it. Seen on CI in build-maven (ubuntu-latest, 17) of run 34241785788: thePeerReceivesTheReplicaOfflineMsgBeforeTheShutdownReturns failed with "the peer replication server was never told that the replica went offline ... no more data", with ERR_SESSION_STARTUP_INTERRUPTED logged for that peer's session, and the shutdown returning after FORWARD_DELAY rather than after the grace period. Issue OpenIdentityPlatform#821 recorded the same window, from the dead handler it used to leave behind. * waitForConnectedReplicationServer() and waitForConnectedDirectoryServer() also wait for the handler to be registered as a monitor provider, which finalizeStart() does by its last statement, after reader.start() and writer.start(). The listen thread serves one handshake at a time, so a peer past that window puts every connection accepted before it past it too. * shutdownIsNotDelayedByTheWriterServingADirectoryServer waits for the directory server handler it is about, which otherwise may not have a writer at all by the time the shutdown runs. Verified with a 300 ms sleep inserted before session.waitForStartup(): without this change the class fails exactly as CI did, with it 13/13 pass, and 13/13 pass again with the sleep removed.
|
CI on The five other ubuntu JDKs, macOS, Windows and CodeQL were green, and the previous run of this branch ( What happenedThe replication log dumped for that test - RS(8226) is its
Not a regression of this PRThe window is in code this PR does not touch, and it is not new: it is #821 seen from the shutdown side - "The leaking window is narrow - from On master the same race fails the same test, only differently: with the peer unregistered before The fix
VerificationWith a 300 ms sleep inserted before
Left outThe production side of the same window: a peer already in |
…nly to a peer which can decode it ReplicaOfflineMsg has no encoding below replication protocol V8: Session.publish() drops what it cannot encode for its peer and returns as if it had sent it, and ServerWriter went on to report the forward regardless of the negotiated version. The shutdown then recorded a peer as told which was sent nothing - and, for an announcement whose recipients were never recorded, believed it relayed and stopped waiting for the peers which still needed it. The phantom forward comes from the writer with no wire work to do, so it systematically won that race. ServerWriter now filters an update the peer cannot decode before it publishes, and logs it: there is genuinely nothing to send, since the message is not part of the protocol version that peer speaks. A ReplicaOfflineMsg dropped there turns into the give-up of OpenIdentityPlatform#947 for that peer, as it does for the generation id filters. The check goes through ReplicationMsg.isEncodableFor(), the queryable form of the null getBytes() already documents, so a message version-gated in the future is covered without a new instanceof. ReplicationServer.awaitReplicaOfflineMsgsForwarded() no longer counts such a peer as somebody to wait for, otherwise a topology whose only peer predates V8 would spend the whole grace period on a message nothing can forward.
…nly to a peer which can decode it ReplicaOfflineMsg has no encoding below replication protocol V8: Session.publish() drops what it cannot encode for its peer and returns as if it had sent it, and ServerWriter went on to report the forward regardless of the negotiated version. The shutdown then recorded a peer as told which was sent nothing - and, for an announcement whose recipients were never recorded, believed it relayed and stopped waiting for the peers which still needed it. The phantom forward comes from the writer with no wire work to do, so it systematically won that race. ServerWriter now filters an update the peer cannot decode before it publishes, and logs it: there is genuinely nothing to send, since the message is not part of the protocol version that peer speaks. A ReplicaOfflineMsg dropped there turns into the give-up of OpenIdentityPlatform#947 for that peer, as it does for the generation id filters. The check goes through ReplicationMsg.isEncodableFor(), the queryable form of the null getBytes() already documents, so a message version-gated in the future is covered without a new instanceof. ReplicationServer.awaitReplicaOfflineMsgsForwarded() no longer counts such a peer as somebody to wait for, otherwise a topology whose only peer predates V8 would spend the whole grace period on a message nothing can forward.
…nly to a peer which can decode it ReplicaOfflineMsg has no encoding below replication protocol V8: Session.publish() drops what it cannot encode for its peer and returns as if it had sent it, and ServerWriter went on to report the forward regardless of the negotiated version. The shutdown then recorded a peer as told which was sent nothing - and, for an announcement whose recipients were never recorded, believed it relayed and stopped waiting for the peers which still needed it. The phantom forward comes from the writer with no wire work to do, so it systematically won that race. ServerWriter now filters an update the peer cannot decode before it publishes, and logs it: there is genuinely nothing to send, since the message is not part of the protocol version that peer speaks. A ReplicaOfflineMsg dropped there turns into the give-up of OpenIdentityPlatform#947 for that peer, as it does for the generation id filters. The check goes through ReplicationMsg.isEncodableFor(), the queryable form of the null getBytes() already documents, so a message version-gated in the future is covered without a new instanceof. ReplicationServer.awaitReplicaOfflineMsgsForwarded() no longer counts such a peer as somebody to wait for, otherwise a topology whose only peer predates V8 would spend the whole grace period on a message nothing can forward.
…nly to a peer which can decode it ReplicaOfflineMsg has no encoding below replication protocol V8: Session.publish() drops what it cannot encode for its peer and returns as if it had sent it, and ServerWriter went on to report the forward regardless of the negotiated version. The shutdown then recorded a peer as told which was sent nothing - and, for an announcement whose recipients were never recorded, believed it relayed and stopped waiting for the peers which still needed it. The phantom forward comes from the writer with no wire work to do, so it systematically won that race. ServerWriter now filters an update the peer cannot decode before it publishes, and logs it: there is genuinely nothing to send, since the message is not part of the protocol version that peer speaks. A ReplicaOfflineMsg dropped there turns into the give-up of OpenIdentityPlatform#947 for that peer, as it does for the generation id filters. The check goes through ReplicationMsg.isEncodableFor(), the queryable form of the null getBytes() already documents, so a message version-gated in the future is covered without a new instanceof. ReplicationServer.awaitReplicaOfflineMsgsForwarded() no longer counts such a peer as somebody to wait for, otherwise a topology whose only peer predates V8 would spend the whole grace period on a message nothing can forward.
…t decode where it is an event, and pin it The version guard warned for every peer it dropped a message for, a directory server included: ReplicationServerDomain.put() never queues a ReplicaOfflineMsg for one and says nothing when it drops it, so the copies which reach the writer there are the ones ReplicaCursor synthesizes while the handler is catching up - and, since an offline CSN never enters the state of a handler, again on every refill of its late queue. That road is traced now; the warning stays for a peer replication server, where the drop is what the shutdown accounts for. The message and its arguments were pinned by no test. Both rows of onlyAPeerWhichCanDecodeTheMessageIsToldTheReplicaWentOffline read the error log now: the V7 row expects one record naming this replication server, the domain, the change and the version it has no encoding for, published as a warning; the V8 row expects none. The case waits for the peer to be served from its queue first, so that the writer is handed the announcement once and the count is not a race with the changelog road. Also: the javadoc of the case stated the cost of a phantom forward as it was before OpenIdentityPlatform#947, and the case pinning the caller side check shared the server id 8234 with the handshake case of OpenIdentityPlatform#987.
…nly to a peer which can decode it ReplicaOfflineMsg has no encoding below replication protocol V8: Session.publish() drops what it cannot encode for its peer and returns as if it had sent it, and ServerWriter went on to report the forward regardless of the negotiated version. The shutdown then recorded a peer as told which was sent nothing - and, for an announcement whose recipients were never recorded, believed it relayed and stopped waiting for the peers which still needed it. The phantom forward comes from the writer with no wire work to do, so it systematically won that race. ServerWriter now filters an update the peer cannot decode before it publishes, and logs it: there is genuinely nothing to send, since the message is not part of the protocol version that peer speaks. A ReplicaOfflineMsg dropped there turns into the give-up of OpenIdentityPlatform#947 for that peer, as it does for the generation id filters. The check goes through ReplicationMsg.isEncodableFor(), the queryable form of the null getBytes() already documents, so a message version-gated in the future is covered without a new instanceof. ReplicationServer.awaitReplicaOfflineMsgsForwarded() no longer counts such a peer as somebody to wait for, otherwise a topology whose only peer predates V8 would spend the whole grace period on a message nothing can forward.
…t decode where it is an event, and pin it The version guard warned for every peer it dropped a message for, a directory server included: ReplicationServerDomain.put() never queues a ReplicaOfflineMsg for one and says nothing when it drops it, so the copies which reach the writer there are the ones ReplicaCursor synthesizes while the handler is catching up - and, since an offline CSN never enters the state of a handler, again on every refill of its late queue. That road is traced now; the warning stays for a peer replication server, where the drop is what the shutdown accounts for. The message and its arguments were pinned by no test. Both rows of onlyAPeerWhichCanDecodeTheMessageIsToldTheReplicaWentOffline read the error log now: the V7 row expects one record naming this replication server, the domain, the change and the version it has no encoding for, published as a warning; the V8 row expects none. The case waits for the peer to be served from its queue first, so that the writer is handed the announcement once and the count is not a race with the changelog road. Also: the javadoc of the case stated the cost of a phantom forward as it was before OpenIdentityPlatform#947, and the case pinning the caller side check shared the server id 8234 with the handshake case of OpenIdentityPlatform#987.
Fixes #917
The bug
DSRSShutdownSynckept one pending entry per domain and per replica, and the firstServerWriterto publish the message cleared it for everybody.ReplicationServerDomain.put()queues aReplicaOfflineMsgfor every connected peer RS,and each of them is served by its own writer, so the faster one released the wait while the
other still had the message in its queue.
ReplicationServer.shutdown()then went on tostopAllServers(true), which clears that queue (MessageHandler.shutdown()) and closes thesession, and the peer never learned the replica went offline.
The window is not scheduling jitter. The writer of a lagging peer is behind up to
queue-size(10000 by default) messages of its own, behindSession.sendQueueand behind TCP,and blocks in
acquirePermitInSendWindow()when its send window is closed - so the grace periodwas systematically granted to the peer which never needed it and denied to the one it exists
for.
What it costs is more than the notification.
ChangeNumberIndexer.getOldestLastAliveCSN()excludes offline replicas from the medium consistency point, and
MultiDomainServerState.getOldestCSNExcluding()computes that point across all ECL enableddomains - so one replica a peer was never told about pins the change number index and the
external changelog of that peer for every domain it serves, until the replica writes again. The
peer has no other way to learn it:
notifyReplicaOfflineis only reached from the forwardedmessage (
ReplicationServerDomain.publishUpdateMsg()), aTopologyMsgdoes not carry it, andnothing is recorded in that peer's own changelog state, so its own restart does not help either.
The fix
The pending entry knows which replication servers the message was queued for, and is released
only once all of them have forwarded it.
put()collects the peer RSs whichpass the generation id filter, hands their ids to
DSRSShutdownSync.replicaOfflineMsgDispatched()and only then queues the message: a peer canforward it as soon as it is in its queue, and a forward reported before the recipients are
known would end the wait at once. A peer the message is not queued for - none connected, a
different generation id - is not waited for, and neither is a peer which connected after the
message was queued, since it was never given it.
replicaOfflineMsgForwarded()takes the serverid of the peer, and the entry is removed when the awaited set empties.
disconnection does not cost the whole grace period: one which stops during the window
(
stopServer(), where the give-up now sits in afinallyof its own so that no exit path -an interrupted lock acquisition, an unregistration which throws - can skip it, since
engageShutdown()is one-shot and no later call will retry); one whose handshake is aborted(
unregisterFailedHandshake()); one whose teardown had already started when the recipientswere recorded; and one whose writer drops the message on the way out - the filter in
ServerWriter.isUpdateMsgFiltered()is wider than the oneput()applies when it queues themessage (it drops anything for a peer whose generation id is unknown), so a peer can be given
a message which is then dropped with nothing ever reporting a forward for it.
put()are ordered on the handler's shutdown flag. BothstopServer()andunregisterFailedHandshake()raise it before they give up, andput()reads it after it has recorded the recipients. Of the two, at least one therefore always sees
the other: a teardown whose give-up ran too early to find a recipient recorded has
necessarily raised the flag
put()goes on to read. Without that ordering an abortedhandshake is invisible to both - its give-up is a no-op and its flag is never raised - and the
message is queued for a handler whose queue was just cleared, which nothing will ever
unregister, so the shutdown waits out the whole grace period.
That covers the message of a replica which picked a remote replication server, which the
collocated one only sees relayed, and the race of A ReplicaOfflineMsg still queued behind an uncommitted change is recorded as sent, and the shutdown then waits its whole grace period for nothing #918 in which the announcement is recorded
after the message it belongs to was already relayed. The caller side check on
getConnectedRSs().isEmpty()inReplicationServer.awaitReplicaOfflineMsgsForwarded()staysfor exactly that case: it is the only thing which keeps a server with no peer connected from
spending its grace period on an announcement no recipient can be recorded for.
The hot path is untouched: the recipient list is built only for a
ReplicaOfflineMsg, everyother update keeps the single allocation free loop.
Tests
DSRSShutdownSyncTest(10 new, 22 total) - the forward of one peer does not end the wait ofthe others and the last one does; a message queued for nobody waits for nothing; a peer which
stops is no longer waited for but does not release the others; the forward of a peer the
message was not queued for changes nothing; the recipients of an earlier announcement are not
taken for those of this one; the fallback with no recipient recorded; a peer which stops
before the message is queued does not end the wait, which is the asymmetry between a
forward and a give-up when nothing is recorded yet; and the wait is woken up by the peer which
goes away.
ReplicationServerShutdownSyncTest(5 new, 13 total). All of them driveput()through areal broker, and assert on the barrier itself through a
DSRSShutdownSyncwhich records whatthe production code reports to it, rather than on a duration alone:
theShutdownWaitsForEveryPeerToBeToldTheReplicaWentOfflineconnects two peers through thereal handshake, one of them advertising a send window of one so that a first change fills it
and its writer blocks on the permit of the
ReplicaOfflineMsg. It now asserts first thatboth writers reported a forward before the shutdown returned, and only then that both peers
received the message: the first assertion cannot race, the second one can be lost to the
Session.close()limitation of [#900] Time the ReplicaOfflineMsg grace period per replica, and spend it where the message can still be forwarded #919, and the order tells the two apart.theShutdownStopsWaitingForAPeerWhoseHandshakeWasAbortedaborts the handshake of a secondpeer inside the dispatch, in the exact window in which
put()has read the peers of thedomain and nothing knows yet which of them the message is for. It asserts that the aborted
peer really was recorded among the recipients - so the window is reproduced and not merely
hoped for - and that the shutdown does not wait for it.
theShutdownStopsWaitingForAPeerWhichDisconnectedholds one of two peers back with a fullsend window so it cannot have forwarded anything, closes it after the message was queued for
it, and pins that
stopServer()strikes it off.theMessageIsNotQueuedForAPeerWhoseGenerationIdDifferspins the recipient side filter: aconnected peer which no longer shares the generation id of the domain is not recorded at all,
which is what the caller side check on
getConnectedRSs()cannot see.theShutdownStopsWaitingForAPeerWhoseMessageTheWriterDroppednow goes throughput()instead of calling the synchronization object by hand: the peer is held back by a full send
window, so the message is queued for it and its writer reaches the filter only once the
window is opened again. It asserts the give-up itself, not a duration.
Every new assertion was watched failing against the code it pins, one mutation at a time:
engageShutdown()out ofunregisterFailedHandshake()theShutdownStopsWaitingForAPeerWhoseHandshakeWasAborted- the shutdown waited for a peer whose handshake had been aborted, and which nothing else will ever strike offfinallyofstopServer()theShutdownStopsWaitingForAPeerWhichDisconnected- the shutdown kept waiting for a forward from a peer which had disconnectedtheMessageIsNotQueuedForAPeerWhoseGenerationIdDiffers- the message was recorded as queued for a peer which does not share the generation id of the domainServerWritertheShutdownStopsWaitingForAPeerWhoseMessageTheWriterDropped- the writer dropped the message without telling the shutdown to stop waiting for the peer it was queued forawaited != nullout ofgiveUpOn()aPeerStoppingBeforeTheMessageIsQueuedDoesNotEndTheWaitforwardedBy()releasing on the first forwardtheShutdownWaitsForEveryPeerToBeToldTheReplicaWentOffline- the wait ended on the first peer forwarding the message, without the writer of the peer which was held back ever reporting one, plus twoDSRSShutdownSyncTestcasesEach of those produced exactly the failures listed and no others.
Regression:
ReplicationServerTest,HandshakeAbortRegistrationTest,HandshakeAbortGenerationIdTest,ReplicationDomainTest,MonitorTest,GenerationIdTest,TopologyViewTest,ReplicationServerFailoverTest,StateMachineTest,ReplicationServerDynamicConfTest,ChangelogBackendTestCase,FileChangelogDBTest,ChangeNumberControlPluginTestCase.Left out
for each of them. A peer wedged for longer than the grace period still loses the message; the
wait is bounded as before and no shutdown can take longer than one grace period in total.
Session, not that it reached the wire -the limitation recorded in [#900] Time the ReplicaOfflineMsg grace period per replica, and spend it where the message can still be forwarded #919. Fixing it belongs in
Session: drainsendQueuebeforeinterrupting the publisher on close, or report the forward from the publisher thread.
publishReplicaOfflineMsg()records the announcementafter
PendingChangeshas already published it, so a recipient list computed before thatrecord finds nothing to attach itself to. The fallback above makes that case no worse than
master, and fixing the ordering belongs to that issue.
connectedRSsandunregisterServerHandlerthemselves: a peer reconnecting under the same id while an announcement is in flight can have
the obligation of the new handler cancelled by the teardown of the old one. Narrowing that
means keying the handler maps on the instance, which is a change of its own.
DSRSShutdownSync.canShutdown(DN)has had no production caller since the wait moved to ablocking one in The ReplicaOfflineMsg grace period is spent on the first message of the process: DSRSShutdownSync latches its timestamp and never resets it #900; it is the only way to read the state without waiting for it, and most of
the unit tests assert through it. The server tests above go through
ReplicationServer.shutdown()instead, but retiring the method is a change of its own.
stopServer()still leaves the handler registered and itsqueue uncleared - only the give-up is now guaranteed. That leak predates this PR.