[#841] Fix flaky InitOnLineTest: notify the requester when a remotely requested export cannot start - #845
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
The fix is correct and worth more than the title suggests: without the ErrorMsg the requester's InitializeTask.runTask parks in while (initState == RUNNING) initStateLock.wait(1000) with no deadline, so a real dsreplication initialize hangs forever, not just the test. I verified ErrorMsg.creationTime is serialized for protocol V4+, so the creationTime > ieCtx.startTime gate in processErrorMsg passes and the notification is actually honoured. Two things to change before merge.
countEntries() is still outside the notified region (medium)
opendj-server-legacy/src/main/java/org/opends/server/replication/service/ReplicationDomain.java:1532,1546 call countEntries() after acquireIEContext succeeds but before the attempt-loop try at :1560. That window is covered neither by the new catch nor by the existing publish at :1663 — the same hang, one line further down. LDAPReplicationDomain.countEntries() throws ERR_INIT_EXPORT_NOT_SUPPORTED deterministically for a backend without LDIF-export support, so a remotely requested total update against such a backend still leaves the requester waiting forever.
Probing it inside the guarded block reports the failure and cannot leak the context, since it runs before the acquire:
// countEntries() is called by initializeRemote(ieCtx, ...) outside the
// region that reports the failure to the requester: probe it here so a
// backend that cannot be exported is notified like any other rejection.
countEntries();
ieCtx = acquireIEContext(false);(Threading the counted value into the private initializeRemote(ieCtx, ...) overload and dropping the two calls would be tidier, but touches a signature.)
initTask == null is not equivalent to "requested by a remote server" (medium)
ReplicationDomain.java:1484. The public initializeRemote(int target, Task initTask) accepts a null task, and ALL_SERVERS reaches the same catch via ERR_FULL_UPDATE_NO_REMOTES and via acquireIEContext. The message then carries destination = ALL_SERVERS (-2), which ReplicationServerDomain.getDestinationServers:1322-1346 fans out to every connected DS and every peer RS; each recipient's processErrorMsg applies it to whatever ImportExportContext is live, aborting an unrelated total update. Nobody is waiting for a reply on that path.
No in-tree caller reaches it today (InitializeTargetTask always passes a non-null task, ExportThread always a concrete serverId), so this is a latent API hazard — but the gate is two lines and matches the ExportThread contract exactly, where serverRunningTheTask == serverToInitialize == requester:
if (initTask == null
&& serverToInitialize != RoutableMsg.ALL_SERVERS
&& serverRunningTheTask != getServerId())The 60 s / 30 s deadlines cannot fire (low)
Both new loops check the deadline only after receive() returns, but the hang you diagnosed is receive() never returning: it consumes MonitorMsg/TopologyMsg/WindowMsg internally and loops, and the RS sends a MonitorMsg to every connected DS every 3 s (ReplServerFakeConfiguration monitoringPeriod), inside the 10 s soTimeout. The guards do catch the ErrorMsg and closed-connection cases, which is the common path now — but "fails fast after 60 s, so the cleanup in finally runs" does not hold, and #841's second half (a timed-out method leaving its ports bound) stays open.
A net that survives a method timeout has to live outside the test thread — TestNG still runs configuration methods after ThreadTimeout, and there is no @AfterMethod in ReplicationTestCase today. Narrow enough not to disturb passing tests, in opendj-server-legacy/src/test/java/org/opends/server/replication/InitOnLineTest.java:
@AfterMethod(alwaysRun = true)
public void releaseLeakedReplicationServers() throws Exception
{
if (replServer1 == null && replServer2 == null && replServer3 == null)
{
// the test method cleaned up after itself
return;
}
log("Releasing the replication servers leaked by a timed out test");
stop(server2, server3);
server2 = server3 = null;
remove(replServer1, replServer2, replServer3);
replServer1 = replServer2 = replServer3 = null;
replDomain = null;
Arrays.fill(replServerPort, 0);
}Fine as a follow-up to #800 / #803 if you prefer — but please soften the claim in the PR description either way.
Nits
- Assert before cleanup: in
opendj-server-legacy/src/test/java/org/opends/server/replication/service/ReplicationDomainTest.java:554,assertFalse(firstExport.isAlive(), ...)precedesdisable(domain1)/remove(replServer), so a slow export thread would abort the rest of thefinallyand leak the domain and the listen port into the next tests — the cascade #841 is about. In practice the thread ends ~1 s afterstop(broker2), so it is the ordering that is wrong, not the timing.InitOnLineTest.afterTest:1401-1404documents the safe shape: capture into a boolean, clean up, assert last. - No
isConnected()guard:ReplicationBroker.publish:2246-2393loops with no sleep for a non-UpdateMsgwhile the session is null andconnectionErroris not yet set. The sibling path atReplicationDomain.java:1663waits up to 10 s for reconnection first; the new one publishes unconditionally. Skipping the notification while disconnected is safe — the requester then fails onERR_INIT_EXPORTER_DISCONNECTIONvia theTopologyMsgbranch ofreceiveEntryBytes. - Fractional override bypass:
LDAPReplicationDomain.initializeRemote:1513-1523throws before delegating tosuper, so it skips the notification. Unreachable from a remote request today (ALL_SERVERS-only), but worth keeping in sync if the gate above lands. - Untested branch: the new test covers only the
acquireIEContextrejection.ERR_FULL_UPDATE_MISSING_REMOTE— the actual #841 trigger — is only avoided viawaitForRemoteReplicas. Direct cover is cheap:initializeRemote(<unknown id>, <unknown id>, NO_INIT_TASK, 100)on a domain with a connected broker, assert the broker receives it. waitForRemoteReplicasis weaker than the guard: it checkscontainsKey, whilegetDsInfoOrNull:1708-1719also requiresprotocolVersion >= V4. Fine for these brokers.- Silent on the exporter: the
ExportThreadcatch logs at trace only, so an admin sees the failure on the requester but no cause on the exporter. Alogger.infoin the new catch would close the loop.
|
Thanks - all points addressed in cff81f9:
|
maximthomas
left a comment
There was a problem hiding this comment.
All six points from the last round are addressed, and the production fix is right: the gate matches the ExportTask call site exactly, no rejection path can leak the import/export context, and the hang it fixes is real and unbounded (InitializeTask.runTask waits on initStateLock with no deadline). I'd merge that half as is.
Everything blocking is in the new @AfterMethod net — and it is load-bearing, so it has to be right. I checked whether the 60 s deadline could make it unnecessary: it cannot fire. HeartbeatThread:98 only publishes when nothing else was sent on the session within the interval, and the RS pushes a MonitorMsg every 3 s (ReplServerFakeConfiguration:61), which ReplicationBroker.receive() consumes internally. receive() never returns, so the loop body — and the deadline check — never runs. Only the config method can recover a timed-out test.
The net resurrects the abandoned test thread, which then cleans up the next test (blocker)
opendj-server-legacy/src/test/java/org/opends/server/replication/InitOnLineTest.java:1447. stop(server2, server3) closes the session the timed-out thread is blocked on (ReplicationBroker.stop() → setConnectedRS(stopped()) → session.close()), receive() returns null, the new fail(...) at :1096 throws, and the thread runs its own finally { afterTest(testCase); } — concurrently with the following @Test. afterTest touches only shared state: cleanConfigEntries(), stop(server2, server3), remove(replServer1..3), Arrays.fill(replServerPort, 0), entriesToCleanup. It has a 5 s ieRunning() wait up front (:1394), so it is routinely late enough to hit the next test.
Verified with a standalone TestNG run: the abandoned thread survives the timeout (a blocking socket read ignores interrupt()), @AfterMethod runs on main, and closing the socket there makes the zombie's finally overlap the next method.
Not stopping the brokers is not an escape — ReplicationServer.remove() closes the sessions too, and with the broker's own shutdown still false receive() enters reStart's infinite reconnect loop. Any working net wakes that thread, so it needs a guard it cannot race:
private volatile boolean releasedByAfterMethod;
@AfterMethod(alwaysRun = true)
public void releaseLeakedReplicationServers()
{
if (replServer1 == null && replServer2 == null && replServer3 == null
&& server2 == null && server3 == null && replDomain == null)
{
return; // the test method cleaned up after itself
}
log("Releasing the replication servers leaked by a timed out test");
// Neutralise the shared state *before* anything can wake the abandoned
// test thread: stopping its broker unblocks receive(), and its own
// finally{afterTest()} would otherwise clean up the *next* test.
releasedByAfterMethod = true;
final ReplicationBroker b2 = server2, b3 = server3;
final ReplicationServer rs1 = replServer1, rs2 = replServer2, rs3 = replServer3;
server2 = server3 = null;
replServer1 = replServer2 = replServer3 = null;
replDomain = null;
Arrays.fill(replServerPort, 0);
try
{
super.cleanConfigEntries();
stop(b2, b3);
remove(rs1, rs2, rs3);
}
catch (Throwable t)
{
// best effort: throwing here skips the rest of the class
log("Failed to release the leaked replication servers: " + t);
}
} private void afterTest(String testCase) throws Exception
{
if (releasedByAfterMethod)
{
// a timed out method's thread, unblocked by releaseLeakedReplicationServers
return;
}
...The flag is read on afterTest's first line and set before anything can wake the thread, so the ordering is one-directional. Reset it in a @BeforeMethod if you want later methods to keep using afterTest.
The net frees the ports but not the leaked domain config entry (blocker)
Without super.cleanConfigEntries(), the synchro-server entry added by connectServer1ToReplServer (InitOnLineTest.java:517) survives the timeout. The next method calls it again and ConfigurationHandler.addEntry:451-453 throws ENTRY_ALREADY_EXISTS. The cascade #841 is about is only half closed — initializeImport stops failing with BindException and starts failing on the config add. afterTest:1408 is the shape to mirror (included in the snippet above).
A throwing @AfterMethod skips the rest of the class (blocker)
opendj-server-legacy/pom.xml:1263 sets configfailurepolicy=skip. The net declares throws Exception and calls rs.remove() + getChangelogDB().removeDB() on servers an abandoned thread may still be using. Confirmed by direct run: 3 tests, 1 ran, 2 skipped — one failure silently becomes a whole-class skip. Hence the catch (Throwable) above.
The net is never exercised by the reported run (medium)
In 20/20 green every method cleans up after itself, so releaseLeakedReplicationServers takes the early return every time. Worth one manual validation: lower -Dorg.opends.test.timeout, or add a scratch method that blocks in receive(), and confirm the following methods still pass.
The notification depends on cross-host clock ordering (low)
ErrorMsg.creationTime is stamped with the exporter's wall clock and, for protocol V4+, transmitted verbatim (opendj-server-legacy/src/main/java/org/opends/server/replication/protocol/ErrorMsg.java:108-111), while the requester gates on errorMsg.getCreationTime() > ieCtx.startTime — its own clock, taken at acquireIEContext (ReplicationDomain.java:824, :1188). The gap between the two stamps is one hop plus validation, so an exporter whose clock is a few ms behind gets its ErrorMsg discarded as stale (logged as ERR_ERROR_MSG_RECEIVED) and the requester hangs exactly as before. Pre-existing gate, but the fix leans on it, and the new test cannot see it — both ends share one JVM clock. Worth a sentence in the PR; a bounded wait in InitializeTask is the durable fix, as a follow-up.
Nits
- Rebase: master
7af51501d5renamedExportThread→ExportTask; the new comment atReplicationDomain.java:1495still says "theExportThreadcontract". Merges cleanly otherwise. - The rejection can bounce: when the requester is not routable,
ReplicationServerDomain.process:1461→replyWithUnreachablePeerMsg:1517sendsERR_NO_REACHABLE_PEERback to the exporter, whose listener applies anyErrorMsgto whatever context is live — so a rejection aimed at DS(2) can abort an in-flight total update to DS(3). Narrow (the RS learns of a new replica before its DSs do, so the notification is usually routable), and no cheap guard exists sinceErrorMsgcarries no correlation id — but worth a line in the comment block. - Residual
countEntries()window:ReplicationDomain.java:1546/:1560still call it outside any notified region. Passing the probed value into the private overload would close it and drop two redundant transactions. - Early-return condition: the net returns when the three
replServer*are null, so a method that times out after opening brokers or adding the domain config but before creating an RS leaks those (covered by the extended condition above). - Message wording: neighbours read
Cannot start total update in domain "%s" from this directory server DS(%d): …(opendj-server-legacy/src/messages/org/opends/messages/replication.properties,_298/_299);_306inverts the order, and its%sdetail re-renders the domain and both server ids.
…ster when a remotely requested export cannot start When a total update is requested by a remote replica (InitializeRequestMsg, no local task), a failure before the export starts - the requester missing from the replicas view after racing the topology propagation, or an import/export already in progress - was thrown without notifying anyone: the requester kept waiting for an InitializeTargetMsg that never came. Send the failure back as an ErrorMsg (best effort), as the routing failure paths already do. On the test side, initializeExportMultiSS published the InitializeRequestMsg right after connecting the brokers, so it could race the TopologyMsg propagation (RS3 -> RS1 -> DS1) and hang for the whole 600 s method timeout in waitForInitializeTargetMsg: the loop ignored ErrorMsg and null, and the 10 s broker soTimeout never fired because the replication server publishes a MonitorMsg every 3 s which the broker consumes internally. The timed-out thread then skipped its finally cleanup and left the replication servers bound to their ports, cascading into the BindException in initializeImport. - wait for the local domain to see the requester in its topology view before publishing the InitializeRequestMsg (initializeExport, initializeExportMultiSS) - make waitForInitializeTargetMsg fail fast on ErrorMsg, on a closed connection and after 60 s - cover the notification with ReplicationDomainTest.remotelyRequestedExportFailureNotifiesRequester
…quested export failure notification - probe countEntries() before acquiring the import/export context so a backend that cannot be exported is reported to the requester too - gate the ErrorMsg notification on the exact ExportThread contract to avoid a hypothetical ErrorMsg(ALL_SERVERS) fan-out from a local caller - skip the notification while the broker is disconnected and log the rejection on the exporter (new NOTE_FULL_UPDATE_REMOTE_REQUEST_REJECTED) - InitOnLineTest: release the replication servers of a timed out test method in an @AfterMethod, outside the abandoned test thread - ReplicationDomainTest: assert after the cleanup in the finally block; cover the ERR_FULL_UPDATE_MISSING_REMOTE rejection
…bandoned test thread - releaseLeakedReplicationServers neutralises the shared fields before closing the leaked sessions; afterTest early-returns on the releasedByAfterMethod flag (reset in @BeforeMethod), so the woken abandoned thread cannot clean up the next test method - the net also removes the leaked domain config entry and never throws: configfailurepolicy=skip would turn one cleanup failure into a whole-class skip - thread the entry count probed during validation into the private initializeRemote overload instead of re-counting three times - reword NOTE_FULL_UPDATE_REMOTE_REQUEST_REJECTED to match its neighbours; document the ErrorMsg bounce path in the rejection comment; ExportThread -> ExportTask after the rename on master
cff81f9 to
516c039
Compare
|
Thanks - all points addressed in 516c039 (rebased on master):
|
Fixes #841
Production fix
When a total update is requested by a remote replica (
InitializeRequestMsg, no local task), a failure before the export starts was thrown without notifying anyone, and the requester kept waiting for anInitializeTargetMsgthat never came. This covers:acquireIEContext), andcountEntries()is now probed before acquiring the import/export context, soERR_INIT_EXPORT_NOT_SUPPORTEDis reported like the other rejections instead of escaping past them; the probed value is threaded into the export itself, dropping the repeated counts.ReplicationDomain.initializeRemotenow sends the failure back to the requester as anErrorMsg, the same way the routing failure paths already do - best effort: only while the broker is connected (a disconnected requester detects the disconnection instead), and gated on the exactExportTaskcontract (no local task, a concrete target, a remote requester), so a hypothetical localinitializeRemote(ALL_SERVERS, null)caller cannot fan anErrorMsg(ALL_SERVERS)out to the whole topology. The rejection is also logged on the exporter (newNOTE_FULL_UPDATE_REMOTE_REQUEST_REJECTEDmessage). The task-initiated path is unchanged: there the local task reports the error and no remote is waiting.Known limitations, both pre-existing gates the notification leans on:
ErrorMsg.creationTimeis stamped with the exporter's wall clock (transmitted verbatim for protocol V4+), while the requester'sprocessErrorMsgcompares it against its own clock (creationTime > ieCtx.startTime). Across hosts, an exporter whose clock lags by more than the request round-trip gets its rejection discarded as stale and the requester falls back to waiting. A bounded wait inInitializeTaskis the durable fix - follow-up material.ErrorMsg(ERR_NO_REACHABLE_PEER), which is applied to whatever import/export context is live there -ErrorMsgcarries no correlation id to guard on. Narrow in practice (the RS learns of a new replica before its DSs do), documented in the comment block.Why the test hung for the full 600 s
initializeExportMultiSSpublished theInitializeRequestMsgright after connecting the brokers, so it could race theTopologyMsgpropagation (RS3 -> RS1 -> DS1). When it lost the race,waitForInitializeTargetMsgwaited forever:ErrorMsgandnull;soTimeoutnever fired, because the replication server publishes aMonitorMsgevery 3 s andReplicationBroker.receive()consumes it internally, resetting the socket timer each time.TestNG then abandoned the timed-out thread (a blocking socket read does not respond to interrupt), the
finallycleanup never ran, the replication servers kept their ports bound and the cached port poisoned the next test - theBindExceptionininitializeImportfrom the issue.Test changes
initializeExport/initializeExportMultiSSwait for the local domain to see the requester in its topology view (waitForRemoteReplicas) before publishing theInitializeRequestMsg;waitForInitializeTargetMsgfails fast onErrorMsgand on a closed connection - the common paths now that the exporter answers. The extra 60 s deadline is only a backstop: it cannot fire whileReplicationBroker.receive()keeps consuming the periodic monitoring traffic internally;InitOnLineTest.releaseLeakedReplicationServers(@AfterMethod, runs on the main thread - TestNG still runs configuration methods after a thread timeout) releases everything a timed out test method left behind: the domain config entry, the brokers, the replication servers and the cached ports. It neutralises the shared fields before closing the leaked sessions - the close unblocks the abandoned test thread, whose ownfinally{afterTest()}would otherwise clean up the next test method;afterTestearly-returns on thereleasedByAfterMethodflag (reset in@BeforeMethod, so later methods keep cleaning up after themselves). The net never throws: withconfigfailurepolicy=skipone cleanup failure would silently skip the rest of the class;ReplicationDomainTest.remotelyRequestedExportFailureNotifiesRequesterdeterministically covers the new notification: a second remotely requested export is rejected while the first one holds the import/export context, and the requester receives theErrorMsg;ReplicationDomainTest.remotelyRequestedExportForUnknownReplicaIsRejectedcovers theERR_FULL_UPDATE_MISSING_REMOTErejection itself (no leaked import/export context); theErrorMsgdelivery cannot be asserted deterministically on this branch - the replication server does not route to a replica it does not know, and a requester connected yet still unknown to the exporter is exactly the race.Testing
mvn -pl opendj-server-legacy -Pprecommit verify "-Dit.test=InitOnLineTest,ReplicationDomainTest": 20/20 green -InitOnLineTest10/10 in 63 s,ReplicationDomainTest10/10 in 68 s.The
@AfterMethodnet was exercised for real, not just by the green run: withorg.opends.test.timeouttemporarily lowered to 60 s and a scratch@Test(priority = -1)blocking inreceive()(mirroring the shape of a real timed out method, including thefinally{afterTest()}), the scratch method is abandoned onThreadTimeoutException, the net logsReleasing the replication servers leaked by a timed out test, and the remaining 10 methods of the class all pass - the #841 cascade, closed end to end.Related: #800 / #803 (replication test listen port allocation), #730 (import/export context leak on failed validation).