Skip to content

Commit d5c1d9c

Browse files
committed
[bugfix] wait for Jetty webapp availability in integration tests
Block in JettyStart until required contexts are isAvailable(); fail fast from ExistWebServer when startup fails. Set exist.jetty.portal.dir for distribution-mode tests. Add direct servlet mappings in shared test web.xml where rewrite-only routing breaks REST/XML-RPC tests. Simplify LoginModuleIT now that Jetty startup is reliable. Fix IndexController index worker chain order (structural before Lucene).
1 parent 2bc743a commit d5c1d9c

9 files changed

Lines changed: 333 additions & 61 deletions

File tree

exist-core/pom.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,6 +1203,7 @@ The BaseX Team. The original license statement is also included below.]]></pream
12031203
<jetty.home>${project.basedir}/../exist-jetty-config/target/classes/org/exist/jetty</jetty.home>
12041204
<exist.configurationFile>${project.build.testOutputDirectory}/conf.xml</exist.configurationFile>
12051205
<exist.jetty.standalone.webapp.dir>${project.build.testOutputDirectory}/standalone-webapp</exist.jetty.standalone.webapp.dir>
1206+
<exist.jetty.portal.dir>${project.basedir}/../exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal</exist.jetty.portal.dir>
12061207
<log4j.configurationFile>${project.build.testOutputDirectory}/log4j2.xml</log4j.configurationFile>
12071208
</systemPropertyVariables>
12081209

@@ -1229,6 +1230,7 @@ The BaseX Team. The original license statement is also included below.]]></pream
12291230
<jetty.home>${project.basedir}/../exist-jetty-config/target/classes/org/exist/jetty</jetty.home>
12301231
<exist.configurationFile>${project.build.testOutputDirectory}/conf.xml</exist.configurationFile>
12311232
<exist.jetty.standalone.webapp.dir>${project.build.testOutputDirectory}/standalone-webapp</exist.jetty.standalone.webapp.dir>
1233+
<exist.jetty.portal.dir>${project.basedir}/../exist-jetty-config/src/main/resources/org/exist/jetty/etc/webapps/portal</exist.jetty.portal.dir>
12321234
<log4j.configurationFile>${project.build.testOutputDirectory}/log4j2.xml</log4j.configurationFile>
12331235
</systemPropertyVariables>
12341236
</configuration>

exist-core/src/main/java/org/exist/indexing/IndexController.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@
4040
import org.w3c.dom.NodeList;
4141

4242
import java.util.ArrayList;
43+
import java.util.Comparator;
4344
import java.util.HashMap;
4445
import java.util.List;
4546
import java.util.Map;
47+
import java.util.TreeMap;
4648
import org.exist.security.PermissionDeniedException;
4749

4850
/**
@@ -56,7 +58,16 @@ public enum CollectionIndexRemovalMode {
5658
CONFIG_ONLY_REINDEX
5759
}
5860

59-
private final Map<String, IndexWorker> indexWorkers = new HashMap<>();
61+
/**
62+
* Stable iteration order for listener chains and {@link #flush()}.
63+
* Alphabetical {@link TreeMap} put Lucene first and broke store-time indexing
64+
* (see {@code LuceneTests} facet/field-type tests). Core indexes run before Lucene.
65+
*/
66+
private static final Comparator<String> INDEX_WORKER_ORDER = Comparator
67+
.comparingInt(IndexController::indexWorkerChainRank)
68+
.thenComparing(Comparator.naturalOrder());
69+
70+
private final Map<String, IndexWorker> indexWorkers = new TreeMap<>(INDEX_WORKER_ORDER);
6071

6172
private final DBBroker broker;
6273
private StreamListener listener = null;
@@ -73,6 +84,23 @@ public IndexController(final DBBroker broker) {
7384
}
7485
}
7586

87+
/**
88+
* Preferred {@link StreamListener} chain order: structural → statistics → Lucene → others.
89+
* Uses index-id substrings so exist-core does not depend on extension modules.
90+
*/
91+
private static int indexWorkerChainRank(final String indexId) {
92+
if (indexId.contains("NativeStructuralIndex")) {
93+
return 0;
94+
}
95+
if (indexId.contains("IndexStatistics")) {
96+
return 1;
97+
}
98+
if (indexId.contains("lucene.LuceneIndex")) {
99+
return 2;
100+
}
101+
return 3;
102+
}
103+
76104
/**
77105
* Configures all index workers registered with the db instance.
78106
*

exist-core/src/main/java/org/exist/jetty/JettyStart.java

Lines changed: 174 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import net.jcip.annotations.GuardedBy;
2525
import org.apache.logging.log4j.LogManager;
2626
import org.apache.logging.log4j.Logger;
27+
import org.eclipse.jetty.ee10.webapp.WebAppContext;
2728
import org.eclipse.jetty.server.*;
2829
import org.eclipse.jetty.server.handler.ContextHandler;
2930
import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
@@ -97,6 +98,8 @@ public class JettyStart extends Observable implements LifeCycle.Listener {
9798
@GuardedBy("this") private int status = STATUS_STOPPED;
9899
@GuardedBy("this") private Optional<Thread> shutdownHookThread = Optional.empty();
99100
@GuardedBy("this") private int primaryPort = 8080;
101+
@GuardedBy("this") private boolean webAppStartedSuccessfully = false;
102+
@GuardedBy("this") private String webAppStartupFailureDetail = null;
100103

101104

102105
public static void main(final String[] args) {
@@ -132,6 +135,23 @@ private static void consoleOut(final String msg) {
132135
System.out.println(msg); //NOSONAR this has to go to the console
133136
}
134137

138+
private static void consoleErr(final String msg) {
139+
System.err.println(msg); //NOSONAR surfaced in Surefire output when test log4j root is OFF
140+
}
141+
142+
private synchronized void recordStartupFailure(final String detail, final Throwable cause) {
143+
webAppStartedSuccessfully = false;
144+
webAppStartupFailureDetail = detail;
145+
if (cause != null) {
146+
logger.fatal("Jetty startup failed: {}", detail, cause);
147+
consoleErr("Jetty startup failed: " + detail);
148+
cause.printStackTrace(System.err); //NOSONAR CI diagnostics when log4j is disabled in tests
149+
} else {
150+
logger.fatal("Jetty startup failed: {}", detail);
151+
consoleErr("Jetty startup failed: " + detail);
152+
}
153+
}
154+
135155
public synchronized void run() {
136156
run(true);
137157
}
@@ -244,12 +264,13 @@ public synchronized void run(final String[] args, final Observer observer) {
244264
DatabaseManager.registerDatabase(xmldb);
245265

246266
} catch (final Exception e) {
247-
logger.error("configuration error: {}", e.getMessage(), e);
248-
e.printStackTrace();
267+
recordStartupFailure("configuration error: " + e.getMessage(), e);
249268
return;
250269
}
251270

252271
try {
272+
webAppStartupFailureDetail = null;
273+
webAppStartedSuccessfully = false;
253274
// load jetty configurations
254275
final List<Path> configFiles = getEnabledConfigFiles(jettyConfig);
255276
final List<Object> configuredObjects = new ArrayList<>();
@@ -344,19 +365,25 @@ public synchronized void run(final String[] args, final Observer observer) {
344365

345366
logger.info("-----------------------------------------------------");
346367

368+
awaitWebAppContextsStarted(getAllHandlers(server.getHandler()));
369+
webAppStartedSuccessfully = true;
370+
webAppStartupFailureDetail = null;
371+
347372
setChanged();
348373
notifyObservers(SIGNAL_STARTED);
349374

350375
} catch (final SocketException e) {
351-
logger.error("----------------------------------------------------------");
352-
logger.error("ERROR: Could not bind to port because {}", e.getMessage());
353-
logger.error(e.toString());
354-
logger.error("----------------------------------------------------------");
376+
recordStartupFailure("Could not bind to port: " + e.getMessage(), e);
355377
setChanged();
356378
notifyObservers(SIGNAL_ERROR);
357379

358380
} catch (final Exception e) {
359-
logger.fatal("An unexpected error occurred, web server can not be started: {}", e.getMessage(), e);
381+
if (webAppStartupFailureDetail == null) {
382+
recordStartupFailure(
383+
e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(), e);
384+
} else {
385+
recordStartupFailure(webAppStartupFailureDetail, e);
386+
}
360387
setChanged();
361388
notifyObservers(SIGNAL_ERROR);
362389
}
@@ -506,6 +533,129 @@ private Optional<Server> startJetty(final List<Object> configuredObjects) throws
506533
return server;
507534
}
508535

536+
/**
537+
* Block until deployed webapps reach the readiness level required for tests.
538+
* <p>
539+
* Every context except the distribution portal at {@code /} must be
540+
* {@link org.eclipse.jetty.server.handler.ContextHandler#isAvailable()} — Jetty returns
541+
* {@code 503} on all paths while unavailable. The portal coexists with {@code /exist} and is
542+
* non-gating.
543+
*/
544+
private void awaitWebAppContextsStarted(final List<Handler> handlers) throws InterruptedException {
545+
final List<WebAppContext> webApps = new ArrayList<>();
546+
for (final Handler handler : handlers) {
547+
if (handler instanceof WebAppContext webApp) {
548+
webApps.add(webApp);
549+
}
550+
}
551+
if (webApps.isEmpty()) {
552+
return;
553+
}
554+
555+
final boolean distributionLayout = isDistributionLayout(webApps);
556+
final long timeoutMs = slowEnvironmentStartupDeadlineMs();
557+
final long deadline = System.currentTimeMillis() + timeoutMs;
558+
while (System.currentTimeMillis() < deadline) {
559+
boolean allReady = true;
560+
for (final WebAppContext webApp : webApps) {
561+
if (webApp.isFailed()) {
562+
throw new IllegalStateException(
563+
"Web application failed to start: " + webApp.getContextPath());
564+
}
565+
if (!isWebAppContextReady(webApp, distributionLayout)) {
566+
allReady = false;
567+
break;
568+
}
569+
}
570+
if (allReady) {
571+
logger.info("All required web application contexts are ready.");
572+
return;
573+
}
574+
Thread.sleep(200);
575+
}
576+
throw new IllegalStateException(
577+
"Web application context did not become ready within " + timeoutMs + "ms: "
578+
+ describePendingWebApps(webApps, distributionLayout),
579+
firstUnavailableCause(webApps));
580+
}
581+
582+
private static Throwable firstUnavailableCause(final List<WebAppContext> webApps) {
583+
for (final WebAppContext webApp : webApps) {
584+
final Throwable unavailable = webApp.getUnavailableException();
585+
if (unavailable != null) {
586+
return unavailable;
587+
}
588+
}
589+
return null;
590+
}
591+
592+
private static boolean isDistributionLayout(final List<WebAppContext> webApps) {
593+
return webApps.stream().anyMatch(webApp -> "/exist".equals(webApp.getContextPath()));
594+
}
595+
596+
/**
597+
* Distribution portal {@code /} only needs {@code isStarted()}. Standalone {@code /} and
598+
* {@code /exist} must be {@code isAvailable()} or HTTP clients see {@code 503}.
599+
*/
600+
private static boolean isWebAppContextReady(final WebAppContext webApp, final boolean distributionLayout) {
601+
if (!webApp.isStarted()) {
602+
return false;
603+
}
604+
if (distributionLayout && "/".equals(webApp.getContextPath())) {
605+
return true;
606+
}
607+
return webApp.isAvailable();
608+
}
609+
610+
private static String describePendingWebApps(final List<WebAppContext> webApps, final boolean distributionLayout) {
611+
final StringBuilder details = new StringBuilder();
612+
for (final WebAppContext webApp : webApps) {
613+
if (webApp.isFailed()) {
614+
continue;
615+
}
616+
if (!isWebAppContextReady(webApp, distributionLayout)) {
617+
if (!details.isEmpty()) {
618+
details.append("; ");
619+
}
620+
details.append(webApp.getContextPath())
621+
.append(" started=").append(webApp.isStarted())
622+
.append(" available=").append(webApp.isAvailable())
623+
.append(" requireAvailable=").append(requiresAvailability(webApp, distributionLayout))
624+
.append(" war=").append(describeWebAppWar(webApp));
625+
final Throwable unavailable = webApp.getUnavailableException();
626+
if (unavailable != null) {
627+
details.append(" unavailableCause=").append(unavailable.getClass().getName())
628+
.append(": ").append(unavailable.getMessage());
629+
}
630+
}
631+
}
632+
return details.isEmpty() ? "unknown" : details.toString();
633+
}
634+
635+
private static String describeWebAppWar(final WebAppContext webApp) {
636+
final String war = webApp.getWar();
637+
if (war != null && !war.isBlank()) {
638+
return war;
639+
}
640+
try {
641+
final Resource baseResource = webApp.getBaseResource();
642+
return baseResource != null ? String.valueOf(baseResource) : "null";
643+
} catch (final Exception e) {
644+
return "unresolved(" + e.getMessage() + ")";
645+
}
646+
}
647+
648+
private static boolean requiresAvailability(final WebAppContext webApp, final boolean distributionLayout) {
649+
return !(distributionLayout && "/".equals(webApp.getContextPath()));
650+
}
651+
652+
private static long slowEnvironmentStartupDeadlineMs() {
653+
if (System.getenv("CI") != null) {
654+
return 180_000L;
655+
}
656+
return 60_000L;
657+
}
658+
509659
private Map<String, String> getConfigProperties(final Path configDir) throws IOException {
510660
final Map<String, String> configProperties = new HashMap<>();
511661

@@ -695,4 +845,21 @@ public synchronized void lifeCycleStopped(final LifeCycle lifeCycle) {
695845
public synchronized int getPrimaryPort() {
696846
return primaryPort;
697847
}
848+
849+
/**
850+
* {@code true} when all required {@link WebAppContext} instances finished startup. Used by
851+
* integration tests to detect swallowed startup failures.
852+
*/
853+
public synchronized boolean isWebAppStartedSuccessfully() {
854+
return webAppStartedSuccessfully;
855+
}
856+
857+
/**
858+
* When {@link #isWebAppStartedSuccessfully()} is {@code false}, holds the last startup failure
859+
* message for test diagnostics (also printed to {@code System.err} because module test log4j
860+
* configs often set {@code Root level="OFF"}).
861+
*/
862+
public synchronized Optional<String> getWebAppStartupFailureDetail() {
863+
return Optional.ofNullable(webAppStartupFailureDetail);
864+
}
698865
}

0 commit comments

Comments
 (0)