Skip to content

Commit 9b51fbf

Browse files
authored
Merge pull request #6532 from duncdrum/dp-flaky-test-fixes
Fix flaky CI tests: HTTP/2 hangs, broker/txn ordering races, and BrokerPool shutdown safety
2 parents 7b9a2d6 + 5477d6c commit 9b51fbf

21 files changed

Lines changed: 508 additions & 149 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ docker run -d --name existdb -p 8080:8080 -p 8443:8443 existdb/existdb:local
9898

9999
### Known build issues
100100

101-
- Full test suite can hang on flaky infrastructure tests (`MoveResourceTest`, `RenameCollectionTest`). Check with `jstack` and kill if stuck >15 min.
101+
- Full test suite can hang on flaky infrastructure tests (`RenameCollectionTest`). Check with `jstack` and kill if stuck >15 min.
102102
- `RenameCollectionTest` "Connection refused" failures are pre-existing and unrelated to XQuery changes.
103+
- `org.exist.xmldb.concurrent.FragmentsTest` and `org.exist.collections.ConcurrencyTest` are excluded from the surefire run (see `exist-core/pom.xml`). `FragmentsTest` hangs during BrokerPool shutdown even locally; `ConcurrencyTest` can deadlock under concurrent collection access.
103104

104105
## Parser (ANTLR 2)
105106

exist-core/pom.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,8 +1216,8 @@ The BaseX Team. The original license statement is also included below.]]></pream
12161216
see https://github.com/eXist-db/exist/issues/3685 -->
12171217
<exclude>org.exist.collections.ConcurrencyTest</exclude>
12181218

1219-
<!-- Passes locally (~54s) but hangs on CI during BrokerPool shutdown,
1220-
causing the entire unit test step to time out.
1219+
<!-- Hangs both locally and on CI during BrokerPool shutdown;
1220+
root cause not fully resolved (isShuttingDownOrDown fix was insufficient).
12211221
see https://github.com/eXist-db/exist/pull/6153 -->
12221222
<exclude>org.exist.xmldb.concurrent.FragmentsTest</exclude>
12231223
</excludes>

exist-core/src/main/java/org/exist/http/ws/QueryExecutor.java

Lines changed: 118 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import java.util.Map;
4646
import java.util.Optional;
4747
import java.util.Properties;
48+
import java.util.concurrent.atomic.AtomicBoolean;
4849

4950
/**
5051
* Executes XQuery expressions for the /ws/eval endpoint with support for
@@ -56,6 +57,9 @@ public final class QueryExecutor {
5657

5758
private static final long PROGRESS_INTERVAL_MS = 500;
5859

60+
static final String WALL_CLOCK_TIMEOUT_MESSAGE =
61+
"The query exceeded the predefined timeout and has been killed.";
62+
5963
private final BrokerPool pool;
6064

6165
public QueryExecutor(final BrokerPool pool) {
@@ -103,13 +107,21 @@ public void execute(final Session wsSession, final EvalSession evalSession,
103107
sendProgress(wsSession, msg.id(), EvalProtocol.PHASE_COMPILING, 0,
104108
System.currentTimeMillis() - startTime);
105109

106-
// Set timeout via watchdog
107110
final XQueryWatchDog watchDog = context.getWatchDog();
111+
watchDog.reset(); // before signal: prevents execute() from clearing a concurrent kill() via its internal watchdog.reset()
108112
if (msg.maxExecutionTime() > 0) {
109113
watchDog.setTimeout(msg.maxExecutionTime());
110114
}
111115
evalSession.registerQuery(msg.id(), watchDog);
112116

117+
final AtomicBoolean terminalResponseSent = new AtomicBoolean(false);
118+
WallClockQueryTimeout wallClockTimeout = null;
119+
if (msg.maxExecutionTime() > 0) {
120+
wallClockTimeout = new WallClockQueryTimeout();
121+
wallClockTimeout.schedule(msg.maxExecutionTime(), () -> handleWallClockTimeout(
122+
wsSession, evalSession, msg, timing, startTime, watchDog, terminalResponseSent));
123+
}
124+
113125
try {
114126
// Evaluate phase
115127
sendProgress(wsSession, msg.id(), EvalProtocol.PHASE_EVALUATING, 0,
@@ -120,16 +132,17 @@ public void execute(final Session wsSession, final EvalSession evalSession,
120132

121133
final Sequence result;
122134
try {
123-
result = xquery.execute(broker, compiled, null, new Properties(), true);
135+
result = xquery.execute(broker, compiled, null, new Properties(), false);
124136
} catch (final TerminatedException e) {
125137
timing.evaluate = System.currentTimeMillis() - evalStart;
126138
reportTerminationOrError(wsSession, evalSession, msg, timing, startTime,
127-
watchDog, ErrorInfo.of(e.getMessage(), e.getLine(), e.getColumn()));
139+
watchDog, ErrorInfo.of(e.getMessage(), e.getLine(), e.getColumn()),
140+
terminalResponseSent);
128141
return;
129142
} catch (final XPathException e) {
130143
timing.evaluate = System.currentTimeMillis() - evalStart;
131144
reportTerminationOrError(wsSession, evalSession, msg, timing, startTime,
132-
watchDog, ErrorInfo.of(e));
145+
watchDog, ErrorInfo.of(e), terminalResponseSent);
133146
return;
134147
}
135148

@@ -147,23 +160,46 @@ public void execute(final Session wsSession, final EvalSession evalSession,
147160
try {
148161
if (msg.stream().enabled() && itemCount > msg.stream().chunkSize()) {
149162
streamResults(wsSession, msg.id(), broker, result, outputProperties,
150-
msg.stream().chunkSize(), timing, startTime, watchDog);
163+
msg.stream().chunkSize(), timing, startTime, watchDog,
164+
terminalResponseSent, evalSession, msg);
151165
} else {
166+
if (watchDog.isTerminating()) {
167+
timing.serialize = System.currentTimeMillis() - serStart;
168+
timing.total = System.currentTimeMillis() - startTime;
169+
reportTerminationOrError(wsSession, evalSession, msg, timing, startTime,
170+
watchDog, ErrorInfo.of(WALL_CLOCK_TIMEOUT_MESSAGE), terminalResponseSent);
171+
return;
172+
}
173+
try {
174+
watchDog.proceed(null);
175+
} catch (final TerminatedException e) {
176+
timing.serialize = System.currentTimeMillis() - serStart;
177+
reportTerminationOrError(wsSession, evalSession, msg, timing, startTime,
178+
watchDog, ErrorInfo.of(e.getMessage()), terminalResponseSent);
179+
return;
180+
}
152181
final String serialized = serializeAll(broker, result, outputProperties);
153182
timing.serialize = System.currentTimeMillis() - serStart;
154183
timing.total = System.currentTimeMillis() - startTime;
155184

156-
sendResult(wsSession, msg.id(), 1, serialized, false, timing, itemCount);
185+
if (terminalResponseSent.compareAndSet(false, true)) {
186+
sendResult(wsSession, msg.id(), 1, serialized, false, timing, itemCount);
187+
QueryMonitorBroadcaster.broadcastEvent("completed", msg.id(), user, msg.query(),
188+
null, itemCount, timing.total);
189+
}
157190
}
158-
QueryMonitorBroadcaster.broadcastEvent("completed", msg.id(), user, msg.query(),
159-
null, itemCount, System.currentTimeMillis() - startTime);
160191
} catch (final SAXException | XPathException e) {
161192
timing.serialize = System.currentTimeMillis() - serStart;
162-
reportError(wsSession, evalSession, msg, timing, startTime, ErrorInfo.of(e.getMessage()));
193+
reportError(wsSession, evalSession, msg, timing, startTime, ErrorInfo.of(e.getMessage()),
194+
terminalResponseSent);
163195
}
164196
} finally {
197+
if (wallClockTimeout != null) {
198+
wallClockTimeout.cancel();
199+
}
165200
evalSession.unregisterQuery(msg.id());
166201
context.runCleanupTasks();
202+
context.reset(); // needed: resetContext=false skipped this in xquery.execute()
167203
}
168204

169205
} catch (final EXistException | PermissionDeniedException e) {
@@ -192,6 +228,16 @@ static ErrorInfo of(final String message) {
192228
private void reportError(final Session wsSession, final EvalSession evalSession,
193229
final EvalProtocol.ClientMessage msg, final EvalProtocol.Timing timing,
194230
final long startTime, final ErrorInfo error) {
231+
reportError(wsSession, evalSession, msg, timing, startTime, error, null);
232+
}
233+
234+
private void reportError(final Session wsSession, final EvalSession evalSession,
235+
final EvalProtocol.ClientMessage msg, final EvalProtocol.Timing timing,
236+
final long startTime, final ErrorInfo error,
237+
@Nullable final AtomicBoolean terminalGate) {
238+
if (terminalGate != null && !terminalGate.compareAndSet(false, true)) {
239+
return;
240+
}
195241
timing.total = System.currentTimeMillis() - startTime;
196242
if (error.xpe() != null) {
197243
sendError(wsSession, msg.id(), error.xpe(), timing);
@@ -207,16 +253,57 @@ private void reportTerminationOrError(final Session wsSession, final EvalSession
207253
final EvalProtocol.Timing timing, final long startTime,
208254
final XQueryWatchDog watchDog,
209255
final ErrorInfo error) {
210-
if (watchDog.isTerminating()) {
256+
reportTerminationOrError(wsSession, evalSession, msg, timing, startTime, watchDog, error, null);
257+
}
258+
259+
private void reportTerminationOrError(final Session wsSession, final EvalSession evalSession,
260+
final EvalProtocol.ClientMessage msg,
261+
final EvalProtocol.Timing timing, final long startTime,
262+
final XQueryWatchDog watchDog,
263+
final ErrorInfo error,
264+
@Nullable final AtomicBoolean terminalGate) {
265+
if (watchDog.isTerminating() && !watchDog.isTimedOut()) {
211266
timing.total = System.currentTimeMillis() - startTime;
212-
sendCancelled(wsSession, msg.id(), 0, timing);
213-
QueryMonitorBroadcaster.broadcastEvent("cancelled", msg.id(),
214-
evalSession.getSubject().getName(), msg.query(), null, 0, timing.total);
267+
sendCancelledIfAbsent(wsSession, msg.id(), evalSession, msg, timing, 0, terminalGate);
215268
} else {
216-
reportError(wsSession, evalSession, msg, timing, startTime, error);
269+
reportError(wsSession, evalSession, msg, timing, startTime, error, terminalGate);
217270
}
218271
}
219272

273+
private void handleWallClockTimeout(final Session wsSession, final EvalSession evalSession,
274+
final EvalProtocol.ClientMessage msg,
275+
final EvalProtocol.Timing timing, final long startTime,
276+
final XQueryWatchDog watchDog,
277+
final AtomicBoolean terminalResponseSent) {
278+
watchDog.killAsTimeout(0);
279+
if (terminalResponseSent.compareAndSet(false, true)) {
280+
timing.total = System.currentTimeMillis() - startTime;
281+
// Use async remote: this callback runs on the shared scheduler thread and must not block
282+
// on client I/O — a stalled connection would otherwise freeze every pending timeout.
283+
try {
284+
wsSession.getAsyncRemote().sendText(
285+
EvalProtocol.errorMessage(msg.id(), null, WALL_CLOCK_TIMEOUT_MESSAGE, 0, 0, timing));
286+
} catch (final IOException e) {
287+
LOG.debug("Failed to send wall-clock timeout error: {}", e.getMessage());
288+
}
289+
QueryMonitorBroadcaster.broadcastEvent("error", msg.id(),
290+
evalSession.getSubject().getName(), msg.query(), null, 0, timing.total);
291+
}
292+
}
293+
294+
private void sendCancelledIfAbsent(final Session wsSession, final String queryId,
295+
final EvalSession evalSession,
296+
final EvalProtocol.ClientMessage msg,
297+
final EvalProtocol.Timing timing, final long items,
298+
@Nullable final AtomicBoolean terminalGate) {
299+
if (terminalGate != null && !terminalGate.compareAndSet(false, true)) {
300+
return;
301+
}
302+
sendCancelled(wsSession, queryId, items, timing);
303+
QueryMonitorBroadcaster.broadcastEvent("cancelled", queryId,
304+
evalSession.getSubject().getName(), msg.query(), null, items, timing.total);
305+
}
306+
220307
/**
221308
* Compile-check a query without executing it.
222309
*/
@@ -273,7 +360,10 @@ private void streamResults(final Session wsSession, final String queryId,
273360
final DBBroker broker, final Sequence result,
274361
final Properties outputProperties, final int chunkSize,
275362
final EvalProtocol.Timing timing, final long startTime,
276-
final XQueryWatchDog watchDog) {
363+
final XQueryWatchDog watchDog,
364+
final AtomicBoolean terminalResponseSent,
365+
final EvalSession evalSession,
366+
final EvalProtocol.ClientMessage msg) {
277367
final long serStart = System.currentTimeMillis();
278368
final long totalItems = result.getItemCount();
279369
int chunkNum = 0;
@@ -282,10 +372,11 @@ private void streamResults(final Session wsSession, final String queryId,
282372
try {
283373
final SequenceIterator iter = result.iterate();
284374
while (iter.hasNext()) {
285-
if (watchDog.isTerminating()) {
375+
if (watchDog.isTerminating() || terminalResponseSent.get()) {
286376
timing.serialize = System.currentTimeMillis() - serStart;
287377
timing.total = System.currentTimeMillis() - startTime;
288-
sendCancelled(wsSession, queryId, itemsSent, timing);
378+
reportTerminationOrError(wsSession, evalSession, msg, timing, startTime,
379+
watchDog, ErrorInfo.of(WALL_CLOCK_TIMEOUT_MESSAGE), terminalResponseSent);
289380
return;
290381
}
291382

@@ -304,11 +395,16 @@ private void streamResults(final Session wsSession, final String queryId,
304395
if (!more) {
305396
timing.serialize = System.currentTimeMillis() - serStart;
306397
timing.total = System.currentTimeMillis() - startTime;
398+
if (terminalResponseSent.compareAndSet(false, true)) {
399+
sendResult(wsSession, queryId, chunkNum, data, false, timing, totalItems);
400+
QueryMonitorBroadcaster.broadcastEvent("completed", queryId,
401+
evalSession.getSubject().getName(), msg.query(),
402+
null, totalItems, timing.total);
403+
}
404+
} else {
405+
sendResult(wsSession, queryId, chunkNum, data, true, null, totalItems);
307406
}
308407

309-
sendResult(wsSession, queryId, chunkNum, data, more,
310-
more ? null : timing, totalItems);
311-
312408
// send progress during streaming
313409
if (more && (System.currentTimeMillis() - startTime) % PROGRESS_INTERVAL_MS < 50) {
314410
sendProgress(wsSession, queryId, EvalProtocol.PHASE_SERIALIZING,
@@ -318,7 +414,8 @@ private void streamResults(final Session wsSession, final String queryId,
318414
} catch (final XPathException | SAXException e) {
319415
timing.serialize = System.currentTimeMillis() - serStart;
320416
timing.total = System.currentTimeMillis() - startTime;
321-
sendError(wsSession, queryId, null, e.getMessage(), 0, 0, timing);
417+
reportError(wsSession, evalSession, msg, timing, startTime, ErrorInfo.of(e.getMessage()),
418+
terminalResponseSent);
322419
}
323420
}
324421

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* eXist-db Open Source Native XML Database
3+
* Copyright (C) 2001 The eXist-db Authors
4+
*
5+
* info@exist-db.org
6+
* http://www.exist-db.org
7+
*
8+
* This library is free software; you can redistribute it and/or
9+
* modify it under the terms of the GNU Lesser General Public
10+
* License as published by the Free Software Foundation; either
11+
* version 2.1 of the License, or (at your option) any later version.
12+
*
13+
* This library is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16+
* Lesser General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Lesser General Public
19+
* License along with this library; if not, write to the Free Software
20+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
21+
*/
22+
package org.exist.http.ws;
23+
24+
import java.util.concurrent.ScheduledExecutorService;
25+
import java.util.concurrent.ScheduledFuture;
26+
import java.util.concurrent.ScheduledThreadPoolExecutor;
27+
import java.util.concurrent.TimeUnit;
28+
import java.util.concurrent.atomic.AtomicBoolean;
29+
30+
/**
31+
* Wall-clock backstop for WebSocket {@code max-execution-time}.
32+
*
33+
* <p>The XQuery watchdog only checks elapsed time inside {@code proceed()}. Under CPU
34+
* pressure the eval thread may not run for long stretches, so a scheduled task guarantees
35+
* the client receives a terminal response when the limit expires.</p>
36+
*/
37+
final class WallClockQueryTimeout {
38+
39+
private static final ScheduledExecutorService EXECUTOR;
40+
static {
41+
final ScheduledThreadPoolExecutor ex = new ScheduledThreadPoolExecutor(1, r -> {
42+
final Thread t = new Thread(r, "exist-ws-eval-wall-clock-timeout");
43+
t.setDaemon(true);
44+
return t;
45+
});
46+
// Remove cancelled futures immediately so they don't retain session/query closures.
47+
ex.setRemoveOnCancelPolicy(true);
48+
EXECUTOR = ex;
49+
}
50+
51+
private final AtomicBoolean triggered = new AtomicBoolean(false);
52+
private volatile ScheduledFuture<?> future;
53+
54+
void schedule(final long delayMs, final Runnable onTimeout) {
55+
future = EXECUTOR.schedule(() -> {
56+
if (triggered.compareAndSet(false, true)) {
57+
onTimeout.run();
58+
}
59+
}, delayMs, TimeUnit.MILLISECONDS);
60+
}
61+
62+
void cancel() {
63+
if (future != null) {
64+
future.cancel(false);
65+
}
66+
}
67+
68+
boolean wasTriggered() {
69+
return triggered.get();
70+
}
71+
}

exist-core/src/main/java/org/exist/storage/BrokerPool.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1255,6 +1255,9 @@ public DBBroker get(final Optional<Subject> subject) throws EXistException {
12551255
synchronized(this) {
12561256
//Are there any available brokers ?
12571257
if(inactiveBrokers.isEmpty()) {
1258+
if(isShuttingDownOrDown()) {
1259+
throw new EXistException("BrokerPool is not operational (state: " + status.getCurrentState().name() + ")");
1260+
}
12581261
//There are no available brokers. If allowed...
12591262
if(brokersCount < maxBrokers)
12601263
//... create one
@@ -1263,7 +1266,7 @@ public DBBroker get(final Optional<Subject> subject) throws EXistException {
12631266
} else
12641267
//... or wait until there is one available
12651268
while(inactiveBrokers.isEmpty()) {
1266-
if(isShuttingDown()) {
1269+
if(isShuttingDownOrDown()) {
12671270
throw new EXistException("BrokerPool is shutting down");
12681271
}
12691272
LOG.debug("waiting for a broker to become available");

0 commit comments

Comments
 (0)