Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d7b9161
Kanban 2207: Release AsyncQueryRequest connections through the lendin…
cnathe Oct 13, 2026
39c8a95
Kanban 2207: Cancel abandoned requests' statements in the database, w…
cnathe Oct 13, 2026
a064720
Kanban 2207: Exempt exports from the read-only request timeout
cnathe Oct 13, 2026
ec56498
Return an error for timed-out requests and count shutdown cancels
cnathe Oct 13, 2026
b99faf7
Merge remote-tracking branch 'origin/develop' into fb_request_kill_2207
cnathe Oct 13, 2026
bb48cc1
isServerCancelledQuery fix
cnathe Oct 13, 2026
e56a94f
Add tests for cancel bookkeeping, the timeout sweep, and cancelled-qu…
cnathe Oct 13, 2026
d62f796
Fix cancel races and wrapped cancelled-query detection
cnathe Oct 13, 2026
e91b066
Add tests for cancel bookkeeping, the timeout sweep, and cancelled-qu…
cnathe Oct 13, 2026
1f08889
Keep a request's cancel state on its RequestTracker
cnathe Oct 13, 2026
b107e2d
drop the PostgreSQL Assume from the new tests.
cnathe Oct 14, 2026
f8bec6f
Use an unpooled connection for the pg_cancel_backend fallback
cnathe Oct 14, 2026
4c78473
Bound the driver cancel's timeout and detect wrapped timed-out aborts
cnathe Oct 14, 2026
f3ebaa2
Log one INFO line per cancelled request and move the step lines to DEBUG
cnathe Oct 14, 2026
a33e8f8
Log an untracked interrupt at INFO and name the exit line's spids as …
cnathe Oct 14, 2026
f36f9ab
Merge remote-tracking branch 'origin/develop' into fb_request_kill_2207
cnathe Oct 14, 2026
b9bef46
Merge remote-tracking branch 'origin/develop' into fb_request_kill_2207
cnathe Oct 15, 2026
1762bdc
Merge remote-tracking branch 'origin/develop' into fb_request_kill_2207
cnathe Oct 16, 2026
5e3be7c
Merge remote-tracking branch 'origin/develop' into fb_request_kill_2207
cnathe Oct 17, 2026
6342a2b
Merge remote-tracking branch 'origin/develop' into fb_request_kill_2207
cnathe Oct 19, 2026
0c668d2
Code review feedback part 1: Rename cancel methods, move cancelSignal…
cnathe Oct 19, 2026
bfc3d44
Code review feedback part 2: Run the cancel fallback before release a…
cnathe Oct 19, 2026
d372239
Code review feedback part 2: Return a 503 from API actions for timed-…
cnathe Oct 19, 2026
cf105fa
Code review feedback part 3: Detach the request thread's connection h…
cnathe Oct 19, 2026
627e150
Code review feedback part 4: Cancel queries at shutdownStarted, just …
cnathe Oct 19, 2026
abcd2ca
Discard connections whose async statement was cancelled
cnathe Oct 19, 2026
b829b3c
Discard a stuck async worker's connection and share the backend check…
cnathe Oct 19, 2026
e412a6f
Cancel non-Postgres queries on timeout and fix review findings
cnathe Oct 19, 2026
77baa1c
Merge remote-tracking branch 'origin/develop' into fb_request_kill_2207
cnathe Oct 20, 2026
1166e96
Merge remote-tracking branch 'origin/develop' into fb_request_kill_2207
cnathe Oct 21, 2026
f972600
Route pipeline cancels through RequestCancellation and connect outsid…
cnathe Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/src/org/labkey/api/ApiModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import org.labkey.api.data.AbstractForeignKey;
import org.labkey.api.data.AccumulatingCommitTask;
import org.labkey.api.data.Aggregate;
import org.labkey.api.data.AsyncQueryRequest;
import org.labkey.api.data.AtomicDatabaseInteger;
import org.labkey.api.data.BindingTestCase;
import org.labkey.api.data.BooleanFormat;
Expand All @@ -78,6 +79,7 @@
import org.labkey.api.data.NameGenerator;
import org.labkey.api.data.PropertyManager;
import org.labkey.api.data.RecordFactory;
import org.labkey.api.data.RequestCancellation;
import org.labkey.api.data.RepeatedCommitTaskMonitor;
import org.labkey.api.data.ResultSetSelectorTestCase;
import org.labkey.api.data.RowTrackingResultSetWrapper;
Expand All @@ -92,6 +94,7 @@
import org.labkey.api.data.Table;
import org.labkey.api.data.TableSelectorTestCase;
import org.labkey.api.data.TempTableInClauseGenerator;
import org.labkey.api.data.TransactionFilter;
import org.labkey.api.data.WorkbookContainerType;
import org.labkey.api.data.dialect.JdbcMetaDataTest;
import org.labkey.api.data.dialect.ParameterSubstitutionTest;
Expand Down Expand Up @@ -528,6 +531,7 @@ public void registerServlets(ServletContext servletCtx)
ApiKeyManager.TestCase.class,
ApiQueryResponse.TestCase.class,
AppPropsTestCase.class,
AsyncQueryRequest.TestCase.class,
AtomicDatabaseInteger.TestCase.class,
BindingTestCase.class,
BlockingCache.BlockingCacheTest.class,
Expand Down Expand Up @@ -573,6 +577,7 @@ public void registerServlets(ServletContext servletCtx)
QueryDataIteratorBuilder.TestCase.class,
QueryView.TestCase.class,
RecordFactory.TestCase.class,
RequestCancellation.TestCase.class,
RepeatedCommitTaskMonitor.IntegrationTestCase.class,
ResultSetDataIterator.TestCase.class,
ResultSetSelectorTestCase.class,
Expand All @@ -591,6 +596,7 @@ public void registerServlets(ServletContext servletCtx)
Table.TestCase.class,
TableSelectorTestCase.CoreTableSelectorTest.class,
TempTableInClauseGenerator.TestCase.class,
TransactionFilter.TestCase.class,
URLHelper.TestCase.class,
UserKeySimpleCache.TestCase.class,
UserManager.TestCase.class,
Expand Down
6 changes: 6 additions & 0 deletions api/src/org/labkey/api/action/SpringActionController.java
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,12 @@ public ModelAndView handleRequest(HttpServletRequest request, @NotNull HttpServl
QueryService.get().setEnvironment(QueryService.Environment.ACTION, actionAnnotation.value());
}

if (ExportAction.class.isAssignableFrom(actionClass) ||
(actionClass.isAnnotationPresent(Action.class) && actionClass.getAnnotation(Action.class).value().equals(ActionType.Export.class)))
{
request.setAttribute(TransactionFilter.EXPORT_ATTRIBUTE_NAME, true);
}

// After the permission check so unauthorized requests can't consume @ConcurrencyLimit permits, and held
// through rendering because the action's memory is live for that whole time.
try (ConcurrencyLimiter.Permit ignored = ConcurrencyLimiter.acquire(actionClass, context))
Expand Down
112 changes: 109 additions & 3 deletions api/src/org/labkey/api/data/AsyncQueryRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
import org.apache.logging.log4j.ThreadContext;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.junit.Assert;
import org.junit.Test;
import org.labkey.api.miniprofiler.MiniProfiler;
import org.labkey.api.miniprofiler.RequestInfo;
import org.labkey.api.query.QueryService;
Expand All @@ -36,6 +38,7 @@
import org.labkey.api.util.UnexpectedException;
import org.labkey.api.util.logging.LogHelper;
import org.labkey.api.view.MockHttpResponseWithRealPassthrough;
import org.springframework.mock.web.MockHttpServletResponse;

import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
Expand All @@ -45,10 +48,13 @@
import java.sql.Statement;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

public class AsyncQueryRequest<T>
{
private static final Logger _log = LogHelper.getLogger(AsyncQueryRequest.class, "Runs DB queries on a background thread");
private static final long WORKER_EXIT_TIMEOUT_MS = 10_000;

private static class CancelledException extends RuntimeException
{
Expand All @@ -63,6 +69,7 @@ private static class CancelledException extends RuntimeException
private final @NotNull Map<String, String> _spanTags;

boolean _cancelled;
boolean _finished;
@Nullable Statement _statement;
@Nullable Span _span;

Expand Down Expand Up @@ -192,7 +199,8 @@ synchronized public T waitForResult(final Callable<T> callable) throws SQLExcept
}
catch (InterruptedException ie)
{
cancel();
cancel(RequestCancellation.getSource(Thread.currentThread()));
awaitWorkerAfterInterrupt(thread);
throw new AbortedRequestException("Interrupted before DB query completed");
}
}
Expand Down Expand Up @@ -241,6 +249,32 @@ synchronized public T waitForResult(final Callable<T> callable) throws SQLExcept
}
}

/**
* Keeps sharing connections until the worker releases the one it borrowed. Otherwise its release can't find the
* request thread's holder, which then stays referenced on an aborted connection that the thread's next request reuses.
*/
synchronized private void awaitWorkerAfterInterrupt(Thread worker)
{
long deadline = System.currentTimeMillis() + WORKER_EXIT_TIMEOUT_MS;
while (!_finished)
{
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0)
{
_log.warn("Async query thread '{}' still running {} ms after its request was interrupted", worker.getName(), WORKER_EXIT_TIMEOUT_MS);
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
          }
      }
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}
try
{
wait(remaining);
}
catch (InterruptedException ignored)
{
// Already cancelling
}
}
}

synchronized public void setResult(T result)
{
if (_cancelled && result instanceof AutoCloseable ac)
Expand All @@ -255,12 +289,14 @@ synchronized public void setResult(T result)
}
}
_result = result;
_finished = true;
notify();
}

synchronized public void setException(Throwable exception)
{
_exception = exception;
_finished = true;
notify();
}

Expand All @@ -269,11 +305,16 @@ synchronized private boolean isCancelled()
return _cancelled;
}

synchronized private void cancel()
synchronized private void cancel(@Nullable RequestCancellation.Source source)
{
_cancelled = true;
if (_span != null)
{
_span.setTag("labkey.async_query.cancelled", true);
if (source != null)
_span.setTag("labkey.cancel.source", source.toString());
}
RequestCancellation.statementCancelled(source, _statement != null);
if (_statement != null)
{
try
Expand All @@ -300,7 +341,8 @@ private IOException checkCancelled()
catch (IOException ioe)
{
_log.debug("ping client: failure: {}", ioe.getMessage());
cancel();
RequestCancellation.requestCancelled(Thread.currentThread(), TransactionFilter.getRequestSummary(Thread.currentThread()), RequestCancellation.Source.CLIENT_DISCONNECT);
cancel(RequestCancellation.Source.CLIENT_DISCONNECT);
return ioe;
}
return null;
Expand All @@ -311,4 +353,68 @@ public StackTraceElement[] getCreationStackTrace()
{
return _creationStackTrace;
}

public static class TestCase extends Assert
{
/** An interrupt must leave the request thread's connection holder released and its next query working */
@Test
public void testInterruptReleasesConnection() throws Exception
{
DbScope scope = DbScope.getLabKeyScope();

for (int i = 0; i < 5; i++)
{
AtomicReference<Throwable> failure = new AtomicReference<>();
AtomicBoolean holderReleased = new AtomicBoolean();
AtomicReference<Throwable> nextQueryFailure = new AtomicReference<>();

Thread requestThread = new Thread(() -> {
try
{
AsyncQueryRequest<Object> request = new AsyncQueryRequest<>(new MockHttpServletResponse(), "testInterruptReleasesConnection", Map.of());
SqlSelector selector = new SqlSelector(scope, "SELECT pg_sleep(30)");
selector.setAsyncRequest(request);
request.waitForResult(() -> selector.getObject(Object.class));
}
catch (Throwable t)
{
failure.set(t);
}

try
{
// Checked before finishedWithThread(), whose cleanup would hide a leak
holderReleased.set(!scope.isThreadConnectionActive());
new SqlSelector(scope, "SELECT 1").getObject(Integer.class);
}
catch (Throwable t)
{
nextQueryFailure.set(t);
}
finally
{
DbScope.finishedWithThread();
}
}, "AsyncQueryRequest interrupt test " + i);

long start = System.currentTimeMillis();
requestThread.start();
while (ConnectionWrapper.getConnectionsByScopeForEffectiveThread(requestThread).isEmpty() && System.currentTimeMillis() - start < 10_000)
{
//noinspection BusyWait
Thread.sleep(50);
}
// Let the statement reach the server
Thread.sleep(300);

requestThread.interrupt();
requestThread.join(30_000);

assertFalse("Request thread didn't exit", requestThread.isAlive());
assertTrue("Expected an aborted request, got " + failure.get(), failure.get() instanceof AbortedRequestException);
assertTrue("Request thread's connection holder is still referenced", holderReleased.get());
assertNull("Next query on the request thread failed", nextQueryFailure.get());
}
}
}
}
54 changes: 46 additions & 8 deletions api/src/org/labkey/api/data/ConnectionWrapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
import java.util.TreeSet;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Predicate;

/**
* Wrapper over another JDBC Connection object that provides logging for errors and some tracking of usage.
Expand Down Expand Up @@ -153,6 +154,9 @@ private void realClose()

private volatile @NotNull Closer _runOnClose = () -> {};

// Set for ConnectionType.Thread; release must go back here even if the closing thread no longer shares the lender's connections
private volatile @Nullable DbScope.ConnectionHolder _threadConnectionHolder;

/** Remember the first SQLException that happened on this connection, as it may have left the connection in a bad state */
private SQLException _originalSqlException;

Expand All @@ -167,13 +171,12 @@ private void realClose()
static
{
// Issue 51483: DB query can be left running after shutting down server
ContextListener.addShutdownListener(ShutdownListener.of("DB connection closer", null, () -> {

// Only kill connections that have been open for more than two seconds to avoid
// interrupting work related to the shutdown
Calendar cutoffCal = Calendar.getInstance();
cutoffCal.add(Calendar.SECOND, -2);
Date cutoffDate = new Date(cutoffCal.getTimeInMillis());
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

}, () -> {
Date cutoffDate = getShutdownCutoff();

// Build up the list and release the lock on _openConnections
List<ConnectionWrapper> toClose = new ArrayList<>();
Expand Down Expand Up @@ -201,6 +204,14 @@ private void realClose()
}));
}

// Only kill connections that have been open for more than two seconds to avoid interrupting work related to the shutdown
private static Date getShutdownCutoff()
{
Calendar cutoffCal = Calendar.getInstance();
cutoffCal.add(Calendar.SECOND, -2);
return new Date(cutoffCal.getTimeInMillis());
}

private static boolean initializeExplicitLogger()
{
var loggerContext = (LoggerContext) LogManager.getContext(false);
Expand Down Expand Up @@ -370,14 +381,25 @@ public static Set<Integer> getSPIDsForThread(Thread t)
* the pool re-hands the same physical connection back out under its cached SPID.
*/
public static Map<DbScope, Set<ConnectionWrapper>> getConnectionsByScopeForThread(Thread t)
{
return getConnectionsByScope(c -> c._allocatingThread == t);
}

/** Like {@link #getConnectionsByScopeForThread}, but also includes connections allocated by threads currently sharing t's connections, such as AsyncQueryRequest workers */
public static Map<DbScope, Set<ConnectionWrapper>> getConnectionsByScopeForEffectiveThread(Thread t)
{
return getConnectionsByScope(c -> c._allocatingThread == t || DbScope.getEffectiveThread(c._allocatingThread) == t);
}

private static Map<DbScope, Set<ConnectionWrapper>> getConnectionsByScope(Predicate<ConnectionWrapper> filter)
{
Map<DbScope, Set<ConnectionWrapper>> result = new HashMap<>();
synchronized(_openConnections)
{
for (ConnectionWrapper c : _openConnections)
{
// A null or negative SPID means the dialect couldn't determine one, so there's nothing to cancel
if (c._allocatingThread == t && c._spid != null && c._spid >= 0)
if (c._spid != null && c._spid >= 0 && filter.test(c))
{
result.computeIfAbsent(c._scope, k -> new HashSet<>()).add(c);
}
Expand All @@ -386,6 +408,22 @@ public static Map<DbScope, Set<ConnectionWrapper>> getConnectionsByScopeForThrea
return result;
}

/** The driver's own connection behind the pool's wrapper, for driver-specific operations like an out-of-band cancel */
public Connection getPhysicalConnection()
{
return DbScope.getDelegate(_connection);
}

@Nullable DbScope.ConnectionHolder getThreadConnectionHolder()
{
return _threadConnectionHolder;
}

void setThreadConnectionHolder(@Nullable DbScope.ConnectionHolder holder)
{
_threadConnectionHolder = holder;
}

/**
* @return whether this wrapper is still open, and so whether its SPID still refers to the session it was handed out
* for. Removal happens before the underlying connection returns to the pool, so a true answer can't be stale in the
Expand Down
Loading
Loading