Repository navigation
Reliably stop abandoned read-only requests and their queries - #8169
Conversation
…g holder after an interrupt
…ith logs and metrics
There was a problem hiding this comment.
I wonder if we should also try an unpooled connection if we fail to get one because the connection pool is maxed out. That's a scenario when we're probably most in need of being able to kill it.
There was a problem hiding this comment.
Per claude: changed to use scope.getUnpooledConnection() instead of getPooledConnection(). An exhausted pool no longer makes the cancel wait up to maxWaitMillis (120 s). Pipeline job cancels go through this code too, so they also stop depending on the pool.
|
@labkey-jeckels I pushed changes per your CR feedback and some logging consolidation. I reran some local manual test cases with my local HTTP/2 poxy in place. @labkey-tchad I'm putting together a manual test plan now and will let you know when it is ready. |
|
@labkey-tchad here is the test plan for this story, including setup steps (will callouts to places that claude can help): https://claude.ai/artifact/7Bo6d5SKVKFaypKybAJE6M?sk=NxhsaaAw_EvO_8v0hYztXw. Let me know if you have any questions on the setup or test cases. |
labkey-jeckels
left a comment
There was a problem hiding this comment.
Overall looking very good. A few small nits and a couple of concerns related to how connections get handled post-cancellation.
One new idea to consider: if we cancel a query on a connection, via any of the cancellation approaches, yank it from the pool. That eliminates potential race conditions from reusing it, which was tricky in my previous work (which was much more limited in scope) in this area
| * Like a {@link #cancelQueries} cancel, but signals each session without opening a database connection. | ||
| * @return the connections it signalled; the caller should fall back to {@link #cancelQueries} for the rest | ||
| */ | ||
| public Set<ConnectionWrapper> cancelQueriesWithoutConnection(Collection<ConnectionWrapper> connections) |
There was a problem hiding this comment.
This method name keeps tripping me up. I understand what it's trying to convey, but it sounds like it's cancelling queries that aren't using a connection, like they were leaked.
I don't love it, but maybe something like cancelQueriesViaOwningConnection()
| // The read-only timeout sweep sends cancels while holding up the request's exit, | ||
| // so don't wait out pgjdbc's 10 s default on an unreachable database. A setting in the JDBC URL or connection properties still wins. | ||
| if (_connectionProperties != null) | ||
| _connectionProperties.putIfAbsent("cancelSignalTimeout", "2"); |
There was a problem hiding this comment.
I think this is a Postgres-specific setting. I doubt it would do harm on other DBs, but consider switching to a dialect method.
| ContextListener.addShutdownListener(ShutdownListener.of("DB connection closer", () -> { | ||
| // Closing doesn't stop a busy backend, which notices its client is gone only once it sends results | ||
| Date cutoffDate = getShutdownCutoff(); | ||
| RequestCancellation.cancelQueries(getConnectionsByScope(c -> c._allocationTime.before(cutoffDate)), RequestCancellation.Source.SHUTDOWN, true); |
There was a problem hiding this comment.
This is running via shutdownPre(), which will fire before all other listeners have had a chance to start their shutdown sequence and might be mid-query. This connection killing probably belongs in shutdownStarted() instead
There was a problem hiding this comment.
From Claude:
Moved the cancel into shutdownStarted(), just before the existing forced close of the same connections, so every listener's shutdownPre() runs first. It still runs early in the shutdownStarted() pass, since this listener registers when ConnectionWrapper loads; that's the same point where develop already force-closes connections more than 2 s old.
| { | ||
| for (Throwable t = ex; t != null; t = getCause(t)) | ||
| { | ||
| if (t instanceof SQLException sqlEx && SqlDialect.isCancelException(sqlEx)) |
There was a problem hiding this comment.
Claude review is concerned that this could match against HY000 which isn't specifically a cancel. It's probably a remnant of when we used jTDS and can be removed from the SQLServer dialect's check now.
There was a problem hiding this comment.
Done, dropped HY000. From Claude: HY000 is SQL Server's generic error state, not a cancel; it was a jTDS leftover.
| if (remaining <= 0) | ||
| { | ||
| _log.warn("Async query thread '{}' still running {} ms after its request was interrupted", worker.getName(), WORKER_EXIT_TIMEOUT_MS); | ||
| return; |
There was a problem hiding this comment.
Claude suggests detaching the thread from the connection here, with a call like:
DbScope.detachConnectionHolders(Thread.currentThread());
where the new method in DbScope looks like:
/** Next connection request from the thread gets a new holder; wrappers already handed out still release through their own */
static void detachConnectionHolders(Thread thread)
{
for (DbScope scope : getInitializedDbScopes())
{
synchronized (scope._threadConnections)
{
scope._threadConnections.remove(thread);
}
}
}
|
|
||
| try (DbScope.ConnectionSharingCloseable ignored = DbScope.shareConnections(thread, Thread.currentThread())) | ||
| { | ||
| DbScope.closeConnectionsForCurrentThreadWithoutReleasingLocks(); |
There was a problem hiding this comment.
The pg_cancel_backend fallback can't reach the request thread's own connections. closeConnectionsForCurrentThreadWithoutReleasingLocks() closes them inside the lock, so by the time cancelQueriesWithNewConnection runs they're no longer allocated and it skips them. If the driver cancel fails (e.g. the cancel socket times out, or accessToUnderlyingConnectionAllowed=false so getPhysicalConnection() isn't a BaseConnection), a timed-out synchronous query like selectDistinct keeps running in Postgres and nothing is logged. That's the case this PR is meant to fix.
Suggest running the fallback inside the lock, before the close:
Map<DbScope, Set<ConnectionWrapper>> unsignalled = RequestCancellation.cancelQueriesWithoutConnection(ConnectionWrapper.getConnectionsByScopeForEffectiveThread(thread), source, cancelled, first);
// Before the close below: once a wrapper returns to the pool its SPID may belong to another request, so the fallback skips it
RequestCancellation.cancelQueriesWithNewConnection(unsignalled, source, cancelled, first);
try (DbScope.ConnectionSharingCloseable ignored = DbScope.shareConnections(thread, Thread.currentThread()))
{
DbScope.closeConnectionsForCurrentThreadWithoutReleasingLocks();
...When the driver cancel works, unsignalled is empty, so normal requests aren't affected. When it doesn't, the request can't exit until its query is cancelled anyway, so holding the lock while the unpooled connection opens doesn't delay anything. Recording SPIDs and cancelling after the close would hit the reuse race that the isAllocated() check is there to prevent. Is there a connect/login timeout on getUnpooledConnection()? Without one, an unreachable database would block the sweep inside the lock.
A test with the driver cancel unavailable (empty set from cancelQueriesWithoutConnection, or a scope with accessToUnderlyingConnectionAllowed=false) that runs a synchronous pg_sleep and checks it fails with 57014 would cover this path.
There was a problem hiding this comment.
From Claude:
Moved the pg_cancel_backend fallback inside the lock, before anything can release the thread's connections, and added TransactionFilter.TestCase.testCancelFallback, which disables the driver cancel and checks that a synchronous pg_sleep fails with 57014. There was no connect timeout (pgjdbc's loginTimeout defaults to waiting forever), so the fallback connection now sets loginTimeout and socketTimeout to 5 s. This covers a driver cancel that can't be sent at all; pgjdbc swallows an IOException from the cancel socket, so a cancel that times out still counts as signalled and skips the fallback.
The new test also exposed a bigger problem: closeConnectionsForCurrentThreadWithoutReleasingLocks() returned the request thread's connection to the pool while the thread was still mid-call on it, and the thread's own release then threw ConnectionAlreadyReleasedException, hiding the 57014. I removed that close: the cancel makes the thread unwind and release its connections itself, and a thread holding no connections is still banned from getting new ones, as before.
…Timeout to the dialect, and drop HY000
…nd discard cancelled connections
…olders when an async worker outlives the interrupt"
…before closing their connections"
Took your suggestion: a cancelled connection is now discarded when it's released instead of going back to the pool, so a late cancel can't hit whoever reuses that backend. Along the way claude removed the timeout sweep's forced close of the request thread's connections, which was returning connections to the pool while they were still in use. I'm doing a round of manual testing with the HTTP/2 proxy in place before I push these changes. I'll ping you once that is done. |
- Dialects without a connection-level cancel now use JDBC Statement.cancel() - close a result set leaked when an async interrupt races setResult - stop tagging timed-out async queries as APM errors - remove code left over from the forced connection close.
|
Manual test results:
Other findings: |
|
I spoke to Trey about the manual testing results. The notes on T2 and T5 were as expected for this story 1 dev changes. The test plan was misleading on what the expected result should be. The two other findings are unrelated to this PR. We should get a separate issue open for the "extra whitespace" part and look into addressing that separately. @labkey-jeckels you should be all clear for your next code review pass. |
labkey-jeckels
left a comment
There was a problem hiding this comment.
A few more ideas. I don't think any are strictly required.
| { | ||
| // The read-only timeout sweep sends cancels while holding up the request's exit, | ||
| // so don't wait out pgjdbc's 10 s default on an unreachable database | ||
| props.putIfAbsent("cancelSignalTimeout", "2"); |
There was a problem hiding this comment.
This will apply to Redshift too, so it might belong in PostgreSql92Dialect. Not sure about the other properties above.
There was a problem hiding this comment.
moved the cancelSignalTimeout default to PostgreSql92Dialect
| // A cancel can land on whatever the session runs next, so a cancelled one is discarded rather than reused | ||
| private volatile boolean _cancelled = false; | ||
|
|
||
| private final Set<StatementWrapper> _statements = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>())); |
There was a problem hiding this comment.
This may only be used for non-Postgres at this point. If so, should we eliminate it?
There was a problem hiding this comment.
keeping it. it's the only way to stop a request blocked on an external SQL Server or Snowflake scope.
There was a problem hiding this comment.
This should probably route through RequestCancellation.cancelQueries() to benefit from the discard pathway, etc.
There was a problem hiding this comment.
Done. cancelForJob goes through RequestCancellation.cancelQueries with a new PIPELINE source. tested locally
| Map<DbScope, Set<ConnectionWrapper>> unsignalled = RequestCancellation.cancelQueriesViaOwningConnection(connections, source, cancelled, first); | ||
|
|
||
| // Inside the lock, so the request can't finish and hand this thread's connections to its next request first | ||
| RequestCancellation.cancelQueriesViaNewConnection(unsignalled, source, cancelled, first); |
There was a problem hiding this comment.
Cancellation can potentially take a while if we hit all the timeouts (2s for the initial cancel, 5s each for connecting and reading on the new connection). We could potentially move some of that out of the synchronized block.
There was a problem hiding this comment.
Done. The fallback now opens its unpooled connection outside it and only takes the lock to re-check the tracker and run pg_cancel_backend.
|
I had a brainstorm after approving - wouldn't things be simpler if we flipped responsibilities for the async query execution. Have the main HTTP thread do the DB queries, and have the ancillary/async thread do the pinging. This could even be done by a virtual thread. This could also be handled as a separate PR, of course. Rough plan compliments of Claude: Most of the hardening in this PR exists because a worker thread shares the request thread's connections and can outlive the request. If we swapped the roles, so
The only callables are
|
…e the cancel lock Also moves the cancelSignalTimeout default to PostgreSql92Dialect so it no longer applies to Redshift.
|
FYI, I'll fold this comment about the larger role-swap comment into the implementation plan and we can come back to it after dev story 2. |
…tion WebSocket (#8275) ## Rationale This PR lets the browser ask the server to stop the read-only requests it has abandoned, such as a grid's queries when you navigate away or a dialog's query when it closes, as part of [Kanban 2224](LabKey/kanban#2224) (dev story 2 of [Kanban 2178](LabKey/kanban#2178)). Story 1 ([#8169](#8169)) stops a request's database work on timeout, disconnect, or shutdown, but a request the client abandons over a live connection keeps running until it finishes. The client sends the cancel over the notification WebSocket, which the page already holds open, and the server stops the request through story 1's cancel path. Targets 26.11. Replaces #8223, which closed when its branch was renamed for the release. ## Related Pull Requests - LabKey/labkey-api-js#230 - LabKey/labkey-ui-components#2108 - #8275 - LabKey/limsModules#2580 ## Changes - Each page identifies itself to the server with a tab id. Aborted read-only requests are cancelled by request id, and closing the tab cancels all of its read-only requests. An abnormal socket drop (1006) or a close by the server cancels nothing. - The server cancels only the user's own read-only, non-export requests from that tab, and logs the cancels it refuses. - On by default. The new "Disable cancelling abandoned requests" optional feature turns it off on both the client and the server. - The Summary Statistics dialog's query stops when the dialog closes ([GH Issue 1568](LabKey/internal-issues#1568)). - A cancel from any source, including story 1's timeouts, that arrives while an audit row is being written now waits until the write completes. A late cancel can no longer lose a compliance audit row, and the request's later queries are still stopped. - A result set whose close fails still releases its connection, and cancelling an already-closed statement no longer logs an error. - A visible tab pings its WebSocket so a proxy doesn't close it as idle, which would silently stop that tab's cancels. A hidden tab doesn't ping. - A request's cancel is resent every few seconds until the request exits, since a cancel that reaches a query before the database starts running it is ignored.

Rationale
This PR makes the server reliably stop a read-only request's database work when the request times out, the client disconnects, or the server shuts down. Previously an interrupt stopped a query only on the AsyncQueryRequest path. Synchronous queries such as selectDistinct and the Summary Statistics dialog kept running in Postgres until they finished, and an interrupted async request could leave its Tomcat thread holding an aborted connection that failed the thread's next request with a 25P02 error.
Tested with the new AsyncQueryRequest and RequestCancellation integration tests and manually against slow test queries: timeouts on async grids and on selectDistinct, a repeated-interrupt soak with no leaked connections, server shutdown with a running query, a long export under a short timeout, and both summary statistics paths.
Related Pull Requests
Changes