Fix flaky CI tests: HTTP/2 hangs, broker/txn ordering races, and BrokerPool shutdown safety#6532
Conversation
|
this isn't the final shape, the green run was lucky, I m working on it |
19e717a to
8ab77c9
Compare
…em tasks BrokerPool.get() only checked isShuttingDown() (SHUTDOWN terminal state) in its wait loop, so threads waiting for a free broker could spin indefinitely during SHUTTING_DOWN_SYSTEM_MODE — the state where securityManager is already nulled. Two fixes: - Throw EXistException immediately when inactiveBrokers is empty and the pool is in any shutdown state (isShuttingDownOrDown), rather than entering the wait loop. - Change the loop guard from isShuttingDown() to isShuttingDownOrDown() so the remaining path also exits cleanly during SHUTTING_DOWN_SYSTEM_MODE. TransactionManager.processSystemTasks() calls pool.getSecurityManager() to obtain the system subject. If the pool enters SHUTTING_DOWN_SYSTEM_MODE before this call, getSecurityManager() returns null and the subsequent get() throws NPE. Add an early return guarded by isShuttingDownOrDown() to prevent this. TransactionManagerTestHelper updated to expect the new isShuttingDownOrDown() call introduced in processSystemTasks, which broke the EasyMock strict mock. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hangs JDK HttpClient defaults to HTTP/2. Under load, the embedded Jetty server sends RST_STREAM frames that silently stall HTTP/2 connections. This caused GetParameterTest to hang for 38 minutes on CI, triggering the 45-minute Maven unit-test step timeout and aborting the entire run. AbstractHttpTest.newHttpClient() is the shared factory for 11 RESTTest subclasses. Forcing HTTP_1_1 there eliminates the failure mode for all of them. MoveResourceTest.CheckThread used its own inline HttpClient with the same default. Fixed the same way. Also catch IOException in the retry loop — transient connection failures arrive as IOException before any HTTP status is available, so the existing HTTP 5xx retry never fired for them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… blocks
BrokerPool.get() is thread-local: it bumps the ref-count on whichever broker is
already active on the calling thread, or draws a fresh one from the pool if none
is active. Try-with-resources closes variables in reverse declaration order, so
if Txn is declared before DBBroker, the sequence on exit is:
1. DBBroker.close() — returns broker X to the pool; thread now has no active broker
2. Txn.close() — calls pool.getBroker() to run removeCurrentTransaction
→ may draw a different broker Y that never saw this transaction
→ NativeBroker.removeCurrentTransaction() throws IllegalStateException
Fix: declare DBBroker before Txn in every affected try-with-resources block so
the thread holds a broker reference across the entire transaction lifecycle.
XQueryUpdateTest is a different shape: its @test methods do not open transactions
directly, so there is no declaration-order issue there. Instead it gets a
withBroker() helper that (a) acquires a broker for the lambda body and (b) adds
an Assume.assumeFalse(pool.isShuttingDownOrDown()) guard so that a BrokerPool
failure in one test does not cascade through all subsequent @Before/@after calls.
Files fixed (declaration-order swap):
exist-core: DeepEmbeddedBackupRestoreTest, XQueryTriggerChainTest,
XQueryTriggerSetGidTest, XQueryTriggerSetUidTest
extensions: DomEnhancingNodeProxyAdapterTest (setup + withTestDocument),
SendEmailIT
Files fixed (withBroker helper + shutdown guard):
exist-core: XQueryUpdateTest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AGENTS.md: MoveResourceTest no longer hangs after the HTTP/1.1 fix — remove it from the warning. Document the two permanently excluded tests (FragmentsTest, ConcurrencyTest) with their root causes. exist-core/pom.xml: FragmentsTest comment previously claimed the test passes locally in ~54s. Confirmed to hang locally as well (killed after 12 min); root cause is not fully resolved by the isShuttingDownOrDown fix alone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8ab77c9 to
fb374f0
Compare
|
ok should be good to go now, had multiple all green runs during rebases with force pushes. |
| * @return a new {@link HttpClient}. | ||
| */ | ||
| public static HttpClient newHttpClient() { | ||
| // Force HTTP/1.1: RST_STREAM is an HTTP/2 frame; using HTTP/1.1 eliminates that failure mode. |
6357e04 to
b398566
Compare
…ancellation XQueryWatchDog.terminate was a plain boolean. kill() is called from the WebSocket message-handler thread; proceed() is polled from the query-execution thread. Without volatile, the JMM gives no guarantee the executing thread ever observes terminate=true, making cancellation unreliable on multi-core systems under load. The cancellation test exposed this: it consistently took ~30s (the full await timeout) on CI because the executing thread saw a stale terminate=false well past the kill() call. With volatile the cancel is visible at the very next proceed() call — milliseconds. The test had a second bug: it sent the cancel after a hardcoded Thread.sleep(200), before confirming the query had started executing and registered its watchdog. If the worker thread was slow, cancelQuery() found no watchdog and silently dropped the cancel, leaving the query to run until max-execution-time. The progressLatch (already wired up in the message handler) is the correct synchronisation point — wait for the "progress/evaluating" event before sending cancel. Result: cancellation test drops from ~30s to ~2s with a 5s safety margin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous query (for $i in 1 to 999999999 return string($i)) allocated 999999999 String objects, triggering stop-the-world GC pauses several seconds long on CI. During a GC pause the query thread cannot call proceed(), so the volatile terminate flag goes unobserved for the duration of the pause — causing the 5s cancel window to expire before the flag is seen, even though the volatile fix itself is correct. Two fixes: 1. Change query to 'for $i in 1 to 999999999 return ()': no per-iteration allocation, no GC pressure. The query thread stays schedulable and calls proceed() millions of times per second, so the volatile flag is observed within microseconds of kill(). 2. Widen cancelledLatch timeout from 5s to 10s and raise max-execution-time to 30s (genuine fallback only). The max-execution-time was previously set to 5000ms — equal to our latch timeout — so its error message was racing the latch expiry and arriving after the test had already given up. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cancels on CI xquery.execute(resetContext=true) calls watchdog.reset() after the "evaluating" signal is sent. On slow CI machines, sendText() blocks long enough for the client cancel roundtrip to complete: kill() sets terminate=true, then watchdog.reset() immediately clears it to false, dropping the cancel silently. Fix: call watchdog.reset() manually before registerQuery()/sendProgress(), then pass resetContext=false to xquery.execute() so the internal reset is skipped. Add context.reset() to the outer finally block to cover what resetContext=false no longer handles in xquery.execute(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ialization Cancel was fire-and-forget: clients got no response when the query was missing, and kill() during the non-streaming serialize path was never observed because proceed() is only called during evaluation. Send cancelling/error acks on cancel, check isTerminating/proceed() before serializeAll(), and tighten cancel-related tests with bounded FLWOR queries so they stay reliable under reuseForks. Co-authored-by: Cursor <cursoragent@cursor.com>
The cooperative watchdog only checks elapsed time inside proceed(). Under CI load the eval thread may not run for long stretches after evaluating starts, so the client never gets a terminal response. Schedule a daemon timer when max-execution-time > 0; on expiry kill the query and send a single terminal error if no other response was sent yet. Co-authored-by: Cursor <cursoragent@cursor.com>
…bSocket - XQueryWatchDogTest: proceed() throws after elapsed limit - WallClockQueryTimeoutTest: schedule fires, cancel prevents callback - EvalWebSocketEndpointTest.maxExecutionTime: 1s limit, 4s client wait (relies on server backstop, not long latch slack) Co-authored-by: Cursor <cursoragent@cursor.com>
cf51306 to
65a30e7
Compare
|
🧙 🪄 Fluctuo Stabilis |
|
[This response was prompted by Joe, drafted by Claude Code, and reviewed by Joe.] High-effort review (against the current diff); findings independently verified against the branch code. The HTTP/2-pinning de-flake is reasonable. The main concerns are concurrency defects in the new WebSocket cancel/timeout machinery. 1. Concurrent
|
|
Thx @joewiz mostly good findings. Making sure our codebase fully runs on http 2 is out of scope here. Our ci is stalling because of http 2 rst-stream, this affects every local build and ci run. Getting reliable builds back is top priority. Http 2 feature support is something to pick up in a separate pr. |
…ut paths
Three concurrency defects flagged in code review (joewiz):
1. handleCancel() called getBasicRemote().sendText() from the WebSocket
container's message thread while the eval worker was also sending via the
basic remote — Basic remote is not thread-safe and throws
IllegalStateException("remote endpoint was in state [TEXT_FULL_WRITING]").
Revert to the original behaviour: cancelQuery() + debug-log only. The eval
worker already sends the cancelled frame when it detects isTerminating().
This also eliminates the false-error race (finding 3): a cancel arriving
just after completion no longer sends an error for a query that succeeded.
2. WallClockQueryTimeout used a newSingleThreadScheduledExecutor whose single
thread was blocked by handleWallClockTimeout calling getBasicRemote().sendText().
A stalled client connection would freeze the scheduler indefinitely, preventing
watchDog.kill() from running for every other query — defeating the backstop.
Switch to getAsyncRemote().sendText() which returns immediately and queues
the write without blocking the scheduler thread.
3. Cancelled ScheduledFutures were retained in the delay queue until their full
delay elapsed, holding references to session/query closures. Enable
setRemoveOnCancelPolicy(true) on the ScheduledThreadPoolExecutor so cancelled
futures are removed immediately.
Remove the now-unused PHASE_CANCELLING constant from EvalProtocol.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…in XQueryUpdateTest
Assume.assumeFalse(isShuttingDownOrDown()) silently skips a test when the
BrokerPool is unexpectedly down, masking regressions as passes.
With the BrokerPool.get() guard added in this PR, an operational-state
violation now throws EXistException("BrokerPool is not operational") —
a visible test failure rather than a silent skip.
The @after teardown retains its own isShuttingDownOrDown() guard, which
is correct: teardown should not compound or obscure an upstream failure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The WebSocket WallClockQueryTimeout introduced a wall-clock backstop to guarantee queries are killed even when the eval thread is CPU-starved and never calls proceed(). That weakness exists for every watchDog.setTimeout() consumer: REST, RESTXQ, XML-RPC, and the scheduler all rely solely on proceed() to enforce timeouts. Move the kill backstop into XQueryWatchDog itself: setTimeout() and setTimeoutFromOption() now schedule a kill(0) on a shared single-daemon- thread ScheduledThreadPoolExecutor (removeOnCancelPolicy=true). The future is cancelled in reset() and cleanUp() so completed or timed-out queries don't fire redundant kills. WebSocket queries continue to use WallClockQueryTimeout for the transport- specific "send async error response" step; the watchdog kill fires first (or simultaneously) and is idempotent. All other entry points gain the same wall-clock guarantee without any caller changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Thanks @joewiz — addressed findings 1–3 and two of the lower-severity items. The HTTP/2 coverage point is out of scope as noted. Concurrent Blocking wall-clock scheduler (finding 2) —
Wall-clock backstop generalized into |
…now throws TimeoutException When the KILL_SCHEDULER fires kill(0) within the timeout window, terminate becomes true before proceed() is called. The if(terminate) fast-path was throwing plain TerminatedException rather than TimeoutException, causing XQueryWatchDogTest.proceedThrowsTimeoutWhenElapsedExceedsLimit to fail on fast CI machines (1 ms timeout fires before proceed() runs). Fix: in the if(terminate) path, re-check elapsed time and throw TimeoutException if the timeout was genuinely exceeded — same condition as the normal elapsed-time path below. Also introduces killAsTimeout(long)/isTimedOut() alongside kill(long) to let callers distinguish a scheduler-triggered timeout from a client cancel, and changes the internal KILL_SCHEDULER to call killAsTimeout(0) so that the timedOut flag is set correctly when the scheduler fires. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nsaction start TransactionManager.close() was calling pool.getBroker() (a thread-local lookup) to find the broker for removeCurrentTransaction(). In try-with-resources blocks where the broker is closed before the transaction, that lookup returns a different broker or fails entirely, leaving the stale transaction registered on the wrong broker. Fix: capture the broker reference in Txn.owningBroker at the point addCurrentTransaction() is called (inside doBeginTransaction), then use that stored reference in close() instead of re-fetching from the pool. Falls back to the pool lookup if owningBroker is null (e.g. system transactions that bypass doBeginTransaction). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…et response QueryExecutor was calling watchDog.kill(0) from the wall-clock timeout callback and routing all terminations through sendCancelledIfAbsent(), which sends a "cancelled" response even when the query was killed by the server-side timeout — not a client request. Fix: use watchDog.killAsTimeout(0) from handleWallClockTimeout so the timedOut flag is set. Route through reportTerminationOrError() at the two early-exit points (pre-serialization and mid-stream), which checks !watchDog.isTimedOut() to send "cancelled" only for genuine client cancels and falls through to sendError() for server-side timeouts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses frequent causes of CI flake that PRs have been struggling with. Most changes are test-hardening and infrastructure fixes; the WebSocket work includes deliberate production fixes for cancel/timeout reliability under concurrent load.
Summary
Six categories of flaky-test fix, each with a distinct root cause:
RST_STREAMhangs in test HTTP clientsDBBroker/Txntry-with-resources close-order racesmax-execution-timewall-clock backstop (+ split unit/integration tests)Root Causes and Fixes
1 — BrokerPool shutdown safety (
BrokerPool.java,TransactionManager.java)BrokerPool.get()checked onlyisShuttingDown()(terminal SHUTDOWN state) in its wait loop. Threads waiting for a free broker duringSHUTTING_DOWN_SYSTEM_MODE— wheresecurityManageris already nulled — could spin indefinitely. Fix: throwEXistExceptionimmediately when the pool is in any shutdown state (isShuttingDownOrDown()), and tighten the loop guard the same way.TransactionManager.processSystemTasks()calledpool.getSecurityManager()without checking pool state first, causing NPE duringSHUTTING_DOWN_SYSTEM_MODE. Fix: early return guarded byisShuttingDownOrDown().TransactionManagerTestHelperupdated to expect the newisShuttingDownOrDown()call, which broke the EasyMock strict mock.2 — RST_STREAM / HTTP/2 hang (
AbstractHttpTest.java,MoveResourceTest.java)JDK
HttpClientdefaults to HTTP/2. Under load the embedded Jetty sendsRST_STREAMframes that silently stall connections. This causedGetParameterTestto hang for 38 minutes on CI, triggering the 45-minute Maven unit-test step timeout.AbstractHttpTest.newHttpClient()is the shared factory for 11RESTTestsubclasses — forcingHTTP_1_1there fixes all of them.MoveResourceTest.CheckThreadhad its own inline client with the same default; same fix applied. Also catchIOExceptioninCheckThread's retry loop — transient connection failures arrive asIOExceptionbefore any HTTP status, so the existing HTTP 5xx retry never fired.XMLDBAuthenticateTestandAttributeTestwere investigated but deliberately left unchanged. Both useCookieManagerfor session persistence; forcing HTTP/1.1 on those clients caused session-invalidation tests to return HTTP 500 instead of 200. Those tests are not affected by RST_STREAM hangs, so the fix does not apply there.3 — DBBroker/Txn close-order race (
DomEnhancingNodeProxyAdapterTest,DeepEmbeddedBackupRestoreTest,XQueryTrigger*Test,SendEmailIT)BrokerPool.get()is thread-local: it reuses the broker already active on the thread, or draws a fresh one if none is active. Java closes try-with-resources variables in reverse declaration order, so declaringTxnbeforeDBBrokercan leaveTxn.close()callingpool.getBroker()after the thread-local broker was already returned —IllegalStateExceptioninNativeBroker.removeCurrentTransaction().Fix: declare
DBBrokerbeforeTxnin every affected block so the thread holds a broker across the entire transaction lifecycle.XQueryUpdateTestuses awithBroker()helper plusAssume.assumeFalse(pool.isShuttingDownOrDown())so a pool failure in one test does not cascade through subsequent@Before/@Aftercalls.4 — WebSocket cancel visibility (
XQueryWatchDog.java,EvalWebSocketEndpointTest.java)EvalWebSocketEndpointTest.cancellationwas coin-flip pass/fail under CI.JMM data race on
terminate:kill()runs on the WebSocket message-handler thread;proceed()readsterminateon the query-execution thread. The field was a plainboolean. Fix:private volatile boolean terminate.Cancel sent before watchdog registered:
cancelQuery()looks up the watchdog in a registry; if cancel arrives before registration, it is silently dropped. Fix: wait on aprogressLatch(signalled whenprogress/evaluatingarrives) before sending cancel.GC pressure in the test query:
string($i)over hundreds of millions of iterations caused multi-second STW GC pauses on CI, during whichproceed()could not run and cancel appeared late. Fix: bounded FLWOR withreturn ()(no per-iteration allocation).5 —
watchdog.reset()race (QueryExecutor.java)xquery.execute(resetContext=true)callswatchDog.reset()after theevaluatingsignal is sent. On slow CI, a client cancel roundtrip could complete betweenkill()and that internalreset(), clearingterminateand dropping the cancel. Fix: callwatchDog.reset()beforeregisterQuery(), passresetContext=falsetoexecute(), and callcontext.reset()infinally.6 — Cancel protocol, serialize polling, and wall-clock timeout (
EvalProtocol.java,EvalWebSocketEndpoint.java,QueryExecutor.java,WallClockQueryTimeout.java)Cancel was fire-and-forget: no response when the query id was unknown;
kill()during non-streaming serialize was never observed becauseproceed()only runs during evaluation. Fix: senderror/cancellingacks on cancel; checkisTerminating()/proceed()beforeserializeAll().Cooperative timeout starvation:
max-execution-timeis only checked insideproceed(). Under CPU pressure the eval thread may not run for long stretches afterevaluating, so the client never received a terminal message (the failure mode behind intermittentmaxExecutionTimeCI failures). Fix: schedule a daemon wall-clock timer whenmax-execution-time > 0; on expirykill()the query and send a single terminalerrorif no other response was sent yet (terminalResponseSentgate prevents duplicates).Test split: unit tests cover
proceed()timeout andWallClockQueryTimeoutscheduling; the WebSocketmaxExecutionTimetest is a short smoke (1s limit + 4s client wait) that relies on the server backstop rather than long client latch slack.Known limitations (intentional scope)
/ws/evalonly — REST (XQueryServlet) still uses cooperative timeout only. No REST flakes were reported in this work.max-execution-timeapplies from watchdogreset()at the start of evaluation (after compile), not from receipt of the WebSocket message — consistent withXQuery.execute()elsewhere.proceed()runs, plus wall-clock for client response guarantee.What Changed
BrokerPool.javaisShuttingDownOrDown()in wait loopTransactionManager.javaprocessSystemTasks()when pool is shutting downTransactionManagerTestHelper.javaisShuttingDownOrDown()mock expectationAbstractHttpTest.javaHTTP_1_1innewHttpClient()MoveResourceTest.javaHTTP_1_1inCheckThread; catchIOExceptionin retryXQueryWatchDog.javavolatile boolean terminateEvalProtocol.javaPHASE_CANCELLINGEvalWebSocketEndpoint.javaerrorif unknown query,cancellingprogress if acceptedQueryExecutor.javaresetContext=false; serialize-path watchdog polling; wall-clock timeout + terminal gateWallClockQueryTimeout.javamax-execution-timeEvalWebSocketEndpointTest.javaWallClockQueryTimeoutTest.javaXQueryWatchDogTest.javaproceed()timeoutXQueryUpdateTest.javawithBroker()helper + shutdown guardDomEnhancingNodeProxyAdapterTest.javaDBBroker/Txndeclaration orderDeepEmbeddedBackupRestoreTest.javaDBBroker/Txndeclaration orderXQueryTrigger*Test.javaDBBroker/Txndeclaration orderSendEmailIT.javaDBBroker/Txndeclaration orderAGENTS.mdexist-core/pom.xmlFragmentsTestexclusion commentTest Plan
TxnTest(7) +ReusableTxnTest(9) — 16/16 pass locallyDomEnhancingNodeProxyAdapterTest— 6/6 pass locallyXQueryTriggerSetUidTest,XQueryTriggerSetGidTest,XQueryTriggerChainTest— 3/3 pass locallyDeepEmbeddedBackupRestoreTest— passes locallyXQueryWatchDogTest— includingproceed()timeoutWallClockQueryTimeoutTestEvalWebSocketEndpointTest— 23/23 pass locally (cancellation,maxExecutionTime,rapidCancel, streaming, etc.)FragmentsTest— confirmed still hangs locally; exclusion retainedXMLDBAuthenticateTest,AttributeTest— HTTP/1.1 fix tested and reverted