[#792] Fail fast when the replication server cannot bind its listen port - #795
[#792] Fail fast when the replication server cannot bind its listen port#795vharseko wants to merge 3 commits into
Conversation
…ot bind its listen port ReplicationServer.initialize() only logged ERR_COULD_NOT_BIND_CHANGELOG and returned normally, so a replication server whose listen port was taken was created without any listener: consumers only learned about it much later, and in an unrelated place, as a "connection refused". The same hole existed on the port change path of applyConfigurationChange(), which reported the failure as ERR_COULD_NOT_CLOSE_THE_SOCKET and left the server without a listen thread. - bind the listen port through bindListenPort(), which retries a few times with a short back-off so that a momentarily occupied port is self-healing; - propagate the failure as a ConfigException instead of swallowing it, and release the changelog DB and the external changelog when start-up is aborted; - report the failure of a port change through the ConfigChangeResult, and validate the new port in isConfigurationChangeAcceptable(), which used to accept every change unconditionally; - when a bind fails, probe the port and log whether it is owned by a live listener or by a socket which does not accept connections; - add ReplicationServer.isListening() and cover both the nominal and the port-in-use cases in ReplicationServerDynamicConfTest. Fixes OpenIdentityPlatform#792
maximthomas
left a comment
There was a problem hiding this comment.
Good diagnosis of #792 and the right direction — failing fast beats a stochastic Connection refused 400 lines away. Verified the branch compiles (mvn -o -pl opendj-server-legacy test-compile, exit 0) and reproduced the socket behaviour below on Linux 6.8 / JDK 17.
One blocker, plus a behaviour change that needs an explicit decision.
Diagnostic probe can lock the listen port for ~60 s (blocker)
describeBindFailure() creates its probe without SO_REUSEADDR:
// opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServer.java
try (Socket probe = new Socket()) // <-- getReuseAddress() == false
{
probe.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), LISTEN_PORT_PROBE_TIMEOUT_MS);
diagnostic = isSelfConnection(probe) ? ... ;
}If the probe hits the very self-connect this branch exists for, closing it leaves a TIME_WAIT that blocks the rebind. Measured:
new Socket().getReuseAddress() = false
new ServerSocket().getReuseAddress() = true
self-connect closed, SO_REUSEADDR=false -> port rebindable after 61790 ms
self-connect closed, SO_REUSEADDR=true -> port rebindable after 0 ms
Because describeBindFailure() is called inside the retry loop (attempts 1–4), a self-connecting probe poisons attempts 2–5, and with the new fail-fast the server then refuses to start for a minute — caused by its own diagnostic.
Every other socket that can land on a replication port already sets the flag, right before the same isSelfConnection check:
opendj-server-legacy/src/main/java/org/opends/server/replication/service/ReplicationBroker.java:1075-1076opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServer.java:406-410opendj-server-legacy/src/main/java/org/opends/server/util/StaticUtils.java:1301(isAddressInUse)opendj-server-legacy/src/test/java/org/opends/server/TestCaseUtils.java:764-765,.../server/AssuredReplicationServerTest.java:833-834
The new probe is the only exception. Fix:
try (Socket probe = new Socket())
{
probe.setReuseAddress(true);
...Worth noting the flip side: because those sockets do set the flag, a self-connect rejected by #729 frees the port in ~0 ms, so the 800 ms retry premise holds — as long as the probe doesn't break it.
Bind failure aborts the entire server, not just replication (high)
ReplicationServer ctor → ReplicationServerListener ctor → MultimasterReplication.initializeSynchronizationProvider() (.../replication/plugin/MultimasterReplication.java:262) → SynchronizationProviderConfigManager.getSynchronizationProvider() rethrows (.../core/SynchronizationProviderConfigManager.java:317-330) → DirectoryServer.startServer():1499.
startServer() (opendj-server-legacy/src/main/java/org/opends/server/core/DirectoryServer.java:1320-1555) has no catch covering that line. Connection handlers are initialized at :1490 but startConnectionHandlers() at :1525 is never reached — so an instance whose replication port is durably taken (orphaned process after an unclean shutdown) now serves no LDAP at all, where it previously came up degraded.
The retry covers the transient case, not this one. Fine as a deliberate choice, but it needs a release/upgrade note rather than arriving as a side effect. The CI motivation in #792 is fully served by failing fast in tests.
ReplicationServerListener leaks itself as a registered config listener (medium)
.../replication/plugin/ReplicationServerListener.java:60-61 registers this before creating the RS at :67. Registration lands in the server-wide config repository (opendj-config/src/main/java/org/forgerock/opendj/config/server/ServerManagedObject.java:1183-1193), keyed by DN — not scoped to the discarded object. finalizeSynchronizationProvider() can't clean up because replicationServerListener was never assigned (null check at MultimasterReplication.java:563).
Harmless when the JVM dies at startup; not harmless for EmbeddedDirectoryServer or a failed-then-retried dsconfig enable, which leaves a dead listener per attempt. Create the RS before registering the listeners, or deregister in a catch.
abortInitialization() can deregister another instance's ECL state (medium)
shutdownExternalChangelog() ends in an unconditional deregisterVirtualAttributeRules(), and deregistration is keyed by attribute name, not identity:
// opendj-server-legacy/src/main/java/org/opends/server/core/VirtualAttributeConfigManager.java:479-488
public void deregister(VirtualAttributeRule rule) { rules.remove(getDummyDN(rule)); }
private DN getDummyDN(VirtualAttributeRule rule) {
return DN.valueOf("cn=" + rule.getAttributeType().getNameOrOID() + ",cn=Virtual Attributes,cn=config");
}So an RS that took the enableExternalChangeLog() early-return (ReplicationServer.java:632-637, changelogBackend == null) and then failed to bind will strip lastexternalchangelogcookie / firstchangenumber / lastchangenumber / changelog from a running RS. Reachable via the previous issue: stale listener + live listener both get applyConfigurationAdd, the second RS fails to bind and deregisters the first one's attributes. Guard the abort with a flag recording whether this instance registered anything.
Bind diagnostic reports the opposite of the truth for non-loopback listeners (medium)
Ran the method verbatim against four holder shapes:
| port holder | wildcard bind | reported diagnostic |
|---|---|---|
listening on 0.0.0.0 |
EADDRINUSE | "another socket is listening" ✅ |
listening on 192.168.x.x |
EADDRINUSE | "held by a socket which does not accept connections" ❌ |
listening on 127.0.0.1 |
EADDRINUSE | "another socket is listening" ✅ |
| client socket bound, not listening (#728 shape) | EADDRINUSE | "held by a socket which does not accept connections" ✅ |
A listener on any non-loopback address gets the exact opposite diagnosis, and the whole point of this line is to be trusted in a post-mortem. Either soften the wording to what was actually observed ("nothing accepted a connection on 127.0.0.1:%d") or probe the configured addresses too.
Also: the isSelfConnection(probe) branch describes the probe's own freshly-created socket, so it can't support "the port is held by a TCP self-connect".
Failed port change leaves the RS deaf with a config that lies (medium)
applyConfigurationChange() sets this.config = configuration before the port block, so a failed rebind leaves no listener, a config advertising the new port, and localPorts never updated. Reporting OPERATIONS_ERROR is an improvement, but nothing recovers until a restart. The old port was free a moment ago — a rollback (this.config = oldConfig, setServerURL(), rebind, restart listen thread) would close it.
Separately, the InterruptedException path never resets stopListen, which is only cleared on the success path — any future listen thread would exit its loop immediately.
Test coverage (low)
assertEquals(getAllInstances().size(), instancesBefore)is a tautology:allInstances.add(this)was already the constructor's last statement onmaster, afterinitialize().abortInitialization()is untested — assert the changelog backend / virtual-attribute state instead.- The retry — the part that actually fixes the flake — has no test. Hold the port, release it from another thread after ~300 ms, assert
isListening(). - No test for the newly-reporting
applyConfigurationChangefailure (OPERATIONS_ERROR+isListening() == false) or forisConfigurationChangeAcceptablerejecting an occupied port.
Nits
- Diagnostic computed 5×: 4 probes in the retry loop + 1 in
initialize()'s catch, evaluated eagerly even when WARN is off. Compute once on final failure and pass it to both messages. (Latency is fine — a refused loopback probe measures ~10 ms, not the 200 ms timeout, so the failure path costs ≈800 ms of sleeps.) - Retry on non-transient errors:
bindListenPort()retries anyIOException, includingPermission deniedon a privileged port. Restrict toBindException. - FD leak:
isConfigurationAcceptable()closestmpSocketonly on the success path; a failingbindskips it. Pre-existing, but the change path now routes through it, so it leaks once per rejected port change. - Sibling swallow:
FileChangelogDB.initializeDB()(.../changelog/file/FileChangelogDB.java:281-299) catchesChangelogExceptionand only logsERR_COULD_NOT_READ_DB— one line above the swallow this PR fixes. An RS with an unreadable changelog still binds and starts its threads. Worth a follow-up issue. - Untranslated text in a localized message:
describeBindFailure()returns English intoERR_COULD_NOT_BIND_CHANGELOG_6's%s, which has fr/de/es/ja/ko/zh translations. Give the two diagnostics their own message keys, or keep them tologger.trace. - Double logging:
initialize()logs the bind error, thenSynchronizationProviderConfigManagerlogs it again asERR_CONFIG_SYNCH_ERROR_INITIALIZING_PROVIDERwith a stack trace. - Dead message:
ERR_COULD_NOT_CLOSE_THE_SOCKET_70now has no caller in main code (8 locale files still carry translations). - Swallowed exception:
describeBindFailure()'s innercatch (IOException e)discardse; alogger.traceException(e)would help when the probe itself misbehaves. - Possible
null:cause.getMessage()can be null, yielding"null (…)".
…eplication bind - set SO_REUSEADDR on the diagnostic probe, as every other socket which can land on a replication port does: a probe which self-connects would otherwise hold the port in TIME_WAIT once closed and defeat the retry it diagnoses; - probe the port once, when giving up, instead of on every attempt, and report only what was observed on the loopback interface: a listener bound to a non loopback address used to be reported as a socket which does not accept connections, i.e. the opposite of the truth; - give the diagnostics their own localizable messages instead of injecting English text into the %s of a localized one; - guard the deregistration of the external changelog virtual attribute rules: they are registered globally, by attribute name, so an aborted instance used to strip them from a running one; - create the replication server before registering the configuration listeners, which were leaked in the server wide configuration repository when the creation failed, and log why dsconfig create-replication-server failed; - roll back to the previous listen port when a port change fails, keep localPorts in sync, and only clear stopListen once the port is bound, so an interrupted change can no longer leave a listen thread which exits at once; - close the socket of isConfigurationAcceptable() on the failure path too; - stop the threads and release the changelog when initialization is aborted; - make stopListen volatile, log the bind failure once instead of twice; - cover the retry, the aborted initialization and the failed port change with tests.
|
Thanks for the very precise review — the socket measurements in particular saved a lot of guessing. Everything is addressed in c47f867, except two items which I deliberately left out, and one where I ended up disagreeing with the framing rather than with the fact. Details below, in your order. Diagnostic probe can lock the listen port for ~60 sFixed: the probe now sets Worth recording how narrow the window is, because it changed how I fixed the rest: for the probe to self-connect, the port must have been released between the failed Bind failure aborts the entire server, not just replicationThe mechanics are exactly as you describe — no So I kept the fail-fast and wrote it up as an explicit Upgrade note in the PR description, naming the case you raised (orphaned process holding the port after an unclean shutdown → server refuses to start instead of running degraded).
|
| probe result | message |
|---|---|
| connect succeeds, not a self-connect | Another socket is listening on 127.0.0.1:%d |
| connect refused | Nothing accepted a connection on 127.0.0.1:%d : the port is held either by a socket bound to another address, or by a socket which does not accept connections |
| self-connect | Nothing holds 127.0.0.1:%d anymore : the port was released after the last attempt to bind it |
The self-connect branch also stopped lying: as you noted, that self-connect is the probe's own, which means the port was free at probe time — that is now what it says.
Failed port change leaves the RS deaf with a config that lies
Fixed with a rollback. The port change is now stopListenThread() + startListenThread(), and on failure rollbackListenPort() restores the previous configuration, rebinds the old port and restarts the listen thread; only a failed rollback leaves the server without a listener, and it adds its own message to the ConfigChangeResult. localPorts is updated on a successful change (it was never updated at all before, even on success).
stopListen is now cleared inside startListenThread(), after the bind succeeds, which fixes the InterruptedException path you found: the flag can no longer stay set with no listener, and a failed bind can no longer leave a listen thread spinning on a closed socket. It also became volatile — it is written by the config-change thread and read by the listen thread.
replServerRollsBackAFailedPortChange covers all of it: rejection by isConfigurationChangeAcceptable(), OPERATIONS_ERROR from applyConfigurationChange(), the server still listening on its previous port, and a broker still able to connect to it.
Test coverage
You were right that the instance-count assertion was worth little. ReplicationServerDynamicConfTest now has four tests: the existing port change, replServerFailsWhenListenPortIsInUse (fail fast + the ECL assertions above), replServerRetriesToBindItsListenPort (port released from another thread while the server retries), and replServerRollsBackAFailedPortChange.
The retry test releases the port after 300 ms against an 800 ms retry budget, so it has margin; it degrades to "the server starts" rather than to a failure if the scheduler is unkind.
Nits
- Diagnostic computed 5× — fixed, see above.
- Retry on non-transient errors — not done, and I think the suggestion does not achieve its goal: the JDK throws
BindExceptionforEACCES("Permission denied") andEADDRNOTAVAILtoo, not only forEADDRINUSE, so restricting the retry toBindExceptionwould still retry a privileged-port failure. Discriminating would mean matching on the message text. The cost of getting it wrong is 800 ms once, on a path which then fails anyway, so I left the retry as is. - FD leak in
isConfigurationAcceptable()— fixed, try-with-resources. - Sibling swallow in
FileChangelogDB.initializeDB()— agreed, and left out on purpose: propagating it means addingthrows ChangelogExceptionto theChangelogDBinterface and every implementation and caller. It is listed under "Not in this PR" and deserves its own issue. - Untranslated text in a localized message — fixed, see the table above. (The
%sstill carries the JDK error text, which is English by nature.) - Double logging — fixed:
initialize()now only throws, and the single log line comes from whoever reports theConfigException(SynchronizationProviderConfigManagerat start-up,ReplicationServerListenerfordsconfig). - Dead message
ERR_COULD_NOT_CLOSE_THE_SOCKET_70— left in place; the constant is generated and the translations are harmless, and removing an ordinal is more churn than it is worth. - Swallowed exception in the probe's inner
catch— fixed,logger.traceException(e). - Possible
nullfromcause.getMessage()— fixed; all these paths now usegetExceptionMessage().
Verification
mvn -o -pl opendj-server-legacy verify -P precommit \
-Dit.test='**/replication/**/*Test.java,**/replication/**/*TestCase.java'
Tests run: 3491, Failures: 0, Errors: 0, Skipped: 0
(including AssuredReplicationServerTest 360, ReplicationServerLoadBalancingTest, StateMachineTest, and ChangelogBackendTestCase 30 in a separate run for the external changelog change.)
maximthomas
left a comment
There was a problem hiding this comment.
All eight items and nine nits from the previous round are addressed in c47f867, and I agree with both pushbacks: the LDAPConnectionHandler precedent justifies the fail-fast, and BindException does cover EACCES/EADDRNOTAVAIL, so restricting the retry to it would not have discriminated. Verified mvn -pl opendj-server-legacy -am -DskipTests test-compile → exit 0.
Four things the new revision introduced or left.
Rollback leaves a half-applied config, and this.config diverges from what was persisted (medium)
applyConfigurationChange() applies the new config in place before the port block:
// opendj-server-legacy/src/main/java/org/opends/server/replication/server/ReplicationServer.java
this.config = configuration; // :1128
disconnectRemovedReplicationServers(oldRSAddresses); // :1130 reads the new list via getConfiguredRSAddresses()
this.changelogDB.setPurgeDelay(getPurgeDelay()); // :1135
this.changelogDB.setComputeChangeNumber(computeCN); // :1142
cryptoSuite.newParameters(...); // :1151rollbackListenPort() restores this.config (:619) but undoes none of that, and the blocks after the port block (:1199 monitoring period, :1208 group id, :1218 weight, :1226 DB dir) then compare oldConfig against config — the same object — so they no-op. Result: purge delay / compute-CN / crypto and dropped peer connections from the new config, group id / weight / monitoring period from the old one.
The persisted config is already the new one — ConfigurationHandler writes before dispatching:
// opendj-server-legacy/src/main/java/org/opends/server/config/ConfigurationHandler.java
:642 writeUpdatedConfig();
:653 final ConfigChangeResult result = listener.applyConfigurationChange(evaluatedNewEntry);So config.ldif says 2000 while the RS listens on 1000. Restart fails again, and the next unrelated change (e.g. --set replication-purge-delay:…) arrives with port 2000 against a live 1000 and re-fires the port block: changing the purge delay silently moves the listen port.
The ports differ, so bind the new one before closing the old one — no window without a listener, nothing to undo, rollbackListenPort() disappears. With a bindListenPort(int port) overload there is no config flip-flop either:
final int newPort = configuration.getReplicationPort();
if (newPort != oldConfig.getReplicationPort())
{
final ServerSocket newSocket;
try
{
newSocket = bindListenPort(newPort); // old socket still open and serving
}
catch (IOException e)
{
this.config = oldConfig; // nothing was touched
ccr.setResultCode(ResultCode.OPERATIONS_ERROR);
ccr.addMessage(bindFailureMessage(newPort, e));
return ccr;
}
stopListenThread(); // safe: the replacement is already bound
listenSocket = newSocket;
setServerURL();
stopListen = false;
listenThread = new ReplicationServerListenThread(this);
listenThread.start();
localPorts.remove(oldConfig.getReplicationPort());
localPorts.add(newPort);
}This also makes the InterruptedException case harmless, removes the failed-rollback branch that can still leave the server deaf, and closes the window where setServerURL() has already moved to the new port while nothing is bound there — today that URL is what peers get in topology messages for the ~1 s the failed bind and the rollback take.
Also, the comment at :1285-1286 is stale — it says applyConfigurationChange() "cannot roll back to the old one", in the commit that added the rollback.
replServerRetriesToBindItsListenPort is timing-dependent in both directions (medium)
// opendj-server-legacy/src/test/java/org/opends/server/replication/server/ReplicationServerDynamicConfTest.java
Thread.sleep(300);
close(portHolder);
...
replicationServer = new ReplicationServer(...); // 5 attempts, 4 × 200 ms = 800 ms budgetTwo unsynchronised clocks, so "degrades to the server starts" covers one direction only:
- Releaser early / RS slow — everything the constructor does before the first bind attempt runs inside the 300 ms:
enableExternalChangeLog(), crypto suite,ReplSessionSecurity, andchangelogDB.initializeDB()(ReplicationServer.java:486-489), which opens aReplicationEnvironmentand replays the changelog state. Overrun that and the port is free at the first attempt — the test passes without exercising the retry. - Releaser late — a GC pause past 800 ms makes the constructor throw
ConfigExceptionand the test fail. A new flake, in the PR that removes one.
Add a package-private attempt counter set by bindListenPort() and assert > 1, so the vacuous-pass direction fails loudly. For the margin, prefer a wall-clock deadline over an attempt count — 800 ms is a guess against an unmeasured transient, and simply raising LISTEN_BIND_ATTEMPTS doubles the worst case on the change path, where a failed bind and a failed rollback bind each pay the full budget.
The rest of the new tests are sound: bindFreePort() binds the wildcard address and leaves the socket in LISTEN (opendj-server-legacy/src/test/java/org/opends/server/TestCaseUtils.java:760-768), so EADDRINUSE is deterministic despite SO_REUSEADDR; and replServerFailsWhenListenPortIsInUse holds whether or not runningServer takes the enableExternalChangeLog() early return, which is exactly the case the new guard fixes.
The ReplicationServerListener reordering trades one leak for its mirror image (low)
Creating the RS before registering the listeners fixes the leaked listener, but both registrations can throw:
// opendj-config/target/generated-sources/.../server/ReplicationSynchronizationProviderCfg.java
:200 void addReplicationServerAddListener(ConfigurationAddListener<ReplicationServerCfg> listener) throws ConfigException;
:222 void addReplicationServerDeleteListener(ConfigurationDeleteListener<ReplicationServerCfg> listener) throws ConfigException;A throw there now propagates out of the constructor with a fully started RS behind it — listen and connect threads, changelog DB, ECL virtual attributes, entries in allInstances and localPorts. MultimasterReplication.java:262 assigns replicationServerListener only on success and :563-565 guards the shutdown with a null check, so nothing can ever stop it. Same class of bug, other end:
if (configuration.hasReplicationServer())
{
replicationServer = new ReplicationServer(configuration.getReplicationServer(), dsrsShutdownSync);
}
try
{
configuration.addReplicationServerAddListener(this);
configuration.addReplicationServerDeleteListener(this);
}
catch (ConfigException e)
{
shutdown(); // releases the replication server just created
throw e;
}Double logging is back on both ConfigChangeResult paths (low)
handleConfigChangeResult() already logs the ccr messages on a non-SUCCESS result:
// opendj-server-legacy/src/main/java/org/opends/server/config/ConfigurationHandler.java:1801
logger.error(ERR_CONFIG_CHANGE_RESULT_ERROR, className, methodName, entryDN, resultCode, adminActionRequired, messages);It is called for applyConfigurationAdd (:491) and applyConfigurationChange (:655), and ServerManagedObject.registerChangeListener (:791-803) routes ReplicationServer through that same handler. So logger.error(...) + ccr.addMessage(...) in ReplicationServer.applyConfigurationChange() (:1169-1171, :1179-1182) and in ReplicationServerListener.applyConfigurationAdd() (:88-91) each log twice — the duplication removed from initialize(). Drop the explicit logger.error on those three sites.
Nits
- Localization regression on the hottest path:
ERR_COULD_NOT_BIND_CHANGELOG_6is translated in 7 locales underopendj-server-legacy/src/messages/org/opends/messages/; start-up now emits one of the three new English-only keys instead. "Port in use" is the message operators actually hit. - Inconsistent exception rendering:
ERR_COULD_NOT_STOP_LISTEN_THREAD.get(e)(ReplicationServer.java:1193) passes the exception where the rest of the PR usesgetExceptionMessage(e);InterruptedExceptionhas a null message, so it renders as barejava.lang.InterruptedException. - Inconsistent result codes:
ReplicationServerListener.applyConfigurationAddreportsCONSTRAINT_VIOLATIONfor a bind failure whileapplyConfigurationChangereportsOPERATIONS_ERRORfor the same condition. abortInitialization()NPE landmine: it dereferenceschangelogDB, assigned at:204, whileenableExternalChangeLog()runs at:199outside the try. Unreachable today, but a null guard would make the method match its javadoc and safe to reuse —enableExternalChangeLog()throwing still leaks a registered changelog backend and any partially registered virtual attribute rules, sinceregisterVirtualAttributeRules()throwing skipsexternalChangelogRegistered = true.isListening()over-promises: returnstruebetween the bind andlistenThread.start()(:600-604). Test-only usage, so harmless.- Test residue: cleanup is
ReplicationTestCase.remove()→rs.remove()andrs.getChangelogDB().removeDB(); the aborted instance is never handed to the test, soreplServerFailsWhenListenPortIsInUseDbstays on disk.abortInitialization()is otherwise equivalent toshutdown()for a pre-bind failure. - Closing keyword vs deferred work:
Fixes #792auto-closes it while items 2 (per-invocation ports inAssuredReplicationServerTestand ~8 siblings) and 3 (rerunFailingTestsCountvs@DataProvider) are deferred. File those — and theFileChangelogDB.initializeDB()swallow — as follow-ups, or drop the keyword.
…the current one A failed port change used to leave a half applied configuration: the purge delay, compute-change-number, the crypto parameters and the disconnection of removed peers were applied from the new configuration, while rollbackListenPort() restored only this.config, which made every block after the port block compare an object with itself and no-op. applyConfigurationChange() now switches the listen port first, before applying anything else, and switchListenPort() binds the new port while the current listen socket is still open and serving. A change which cannot be applied therefore leaves the replication server exactly as it was: nothing to roll back, no window without a listener, and no window during which the server URL advertises a port nothing listens to. An interrupted stop of the listen thread closes the socket bound for the new port and keeps stopListen set, so no second listen thread can accept on it. Also: * ReplicationServerListener shuts the replication server down when registering the configuration listeners fails, instead of leaving a started server nothing can stop; * the explicit logger.error() calls on the ConfigChangeResult paths are removed, as ConfigurationHandler.handleConfigChangeResult() already logs the result messages; * the bind failure message is built again on the translated ERR_COULD_NOT_BIND_CHANGELOG, with the loopback diagnostic appended as its own message key; * enableExternalChangeLog() runs inside the try which aborts the initialization, and externalChangelogRegistered is set before the virtual attribute rules are registered, so a partial registration is released too; * replServerRetriesToBindItsListenPort releases the port when the bind has actually failed, using the new listenPortBindFailures counter, and asserts the retry was exercised.
|
Thanks again — the second round found the real weakness of the first fix. All four items are Rollback leaves a half-applied configYou were right that restoring
Two details from your sketch which needed care: the socket bound for the new port must be closed On reachability, for the record:
|
Fixes #792 — and closes follow-up item 1 recorded in #728.
Problem
ReplicationServer.initialize()only loggedERR_COULD_NOT_BIND_CHANGELOGand returnednormally. A replication server whose listen port was taken was therefore created without
any listener: the constructor "succeeded", the object was registered, and every consumer
(tests, embedded server, production start-up) only learned about it much later, and in an
unrelated place, as a
Connection refused. That is exactly how #792 surfaced — the realerror and the reported failure were ~400 lines and one test helper apart.
The port change path of
applyConfigurationChange()had the same shape: the old listensocket is closed first, so a failing rebind leaves the server without a listen thread,
while the log says
ERR_COULD_NOT_CLOSE_THE_SOCKET— a message about an error that didnot happen.
Changes
bindListenPort()retries the bind a few times (5 attempts, 200 ms apart), which makes amomentarily occupied port self-healing instead of fatal.
initialize()propagates the failure as aConfigExceptionrather than swallowing it,and
abortInitialization()stops the threads, releases the changelog DB and the externalchangelog so no half initialized instance is left behind (it is never returned to the
caller, so nothing would ever shut it down). It only deregisters the external changelog
virtual attribute rules when this instance registered them: they are held globally, by
attribute name, so releasing them blindly would strip them from a running instance.
applyConfigurationChange()reports a failed port change through theConfigChangeResult(OPERATIONS_ERROR+ message) and goes back to the previous listenport, instead of leaving the server deaf with a configuration advertising a port it does
not listen to.
isConfigurationChangeAcceptable()now validates the new port instead ofaccepting every change unconditionally.
message says what was observed on the loopback interface: another socket listening, a
port which no longer holds anything, or a port which accepts no connection there — which
covers both a socket bound to another address and a socket which does not listen at all.
The probe sets
SO_REUSEADDR, like every other socket which can land on a replicationport, so that a probe which self-connects cannot hold the port in
TIME_WAITand defeatthe retry it is diagnosing.
ReplicationServerListenercreates the replication server before registering itself as aconfiguration listener — a listener registered by an object which is then discarded stays
in the server wide configuration repository — and
applyConfigurationAdd()logs andreports the reason of the failure, so
dsconfig create-replication-serverno longerreturns a bare error.
ReplicationServer.isListening().stopListenbecamevolatile: it is written bythe thread applying a configuration change and read by the listen thread.
Upgrade note
A replication server whose listen port is durably occupied now fails to start, and with it
the whole directory server: the
ConfigExceptionpropagates fromMultimasterReplication.initializeSynchronizationProvider()up toDirectoryServer.startServer(), which does not catch it, so the connection handlers arenever started.
This is the intended fail-fast and it matches what OpenDJ already does for every other
listener —
LDAPConnectionHandler.initializeConnectionHandler()throws anInitializationExceptionwhen its port is in use and the server does not start either —but it differs from the previous behaviour of the replication port, which was to log
ERR_COULD_NOT_BIND_CHANGELOGand come up degraded, without replication. A deploymentwhose replication port is held by an orphaned process after an unclean shutdown will now
see the server refuse to start rather than run without replication.
Tests
ReplicationServerDynamicConfTestcovers:replServerFailsWhenListenPortIsInUse— port held by a socket →ConfigException, thefailed instance is not left registered, and the external changelog of the replication
server which is already running (virtual attribute rules, changelog backend) is left
untouched;
replServerRetriesToBindItsListenPort— the port is released while the replicationserver is retrying, which must be enough for it to start;
replServerRollsBackAFailedPortChange— a change to a port in use is rejected byisConfigurationChangeAcceptable(), and a change applied anyway reportsOPERATIONS_ERRORand leaves the replication server listening on its previous port,still usable by a broker;
isListening()assertions before and after a port change in the existing test.Locally, with
-P precommit, the whole replication package:Not in this PR
AssuredReplicationServerTestand the ~8 otherreplication tests which pin their ports in
@BeforeClass(item 2 of Flaky test: AssuredReplicationServerTest#testSafeDataFromRS — RS silently starts without a listener when the replication port is briefly occupied #792). The retrymakes the transient case self-healing, so this became optional rather than necessary.
rerunFailingTestsCount=3actually applies to TestNG@DataProviderdrivenmethods (item 3 of Flaky test: AssuredReplicationServerTest#testSafeDataFromRS — RS silently starts without a listener when the replication port is briefly occupied #792) — still unverified.
FileChangelogDB.initializeDB()swallowsChangelogExceptionand only logsERR_COULD_NOT_READ_DB, so a replication server with an unreadable changelog still bindsand starts its threads. Propagating it means changing the
ChangelogDBinterface andevery implementation, which belongs to its own change.