Add internal timeouts to RDBMS query, count, and aggregate, with timeoutSeconds field on their inputs; also add cancel method on those 3 actions, implemented down in RDBMS as well (e.g., to cancel inresponse to http request being abandoned)

This commit is contained in:
2023-07-20 20:10:03 -05:00
parent c53f5e935d
commit 0ff98ce7ea
13 changed files with 465 additions and 20 deletions

View File

@ -67,4 +67,15 @@ public interface BaseQueryInterface
}
}
/*******************************************************************************
**
*******************************************************************************/
default void cancelAction()
{
//////////////////////////////////////////////
// initially at least, a noop in base class //
//////////////////////////////////////////////
}
}

View File

@ -26,6 +26,7 @@ import com.kingsrook.qqq.backend.core.actions.ActionHelper;
import com.kingsrook.qqq.backend.core.actions.interfaces.AggregateInterface;
import com.kingsrook.qqq.backend.core.actions.tables.helpers.QueryStatManager;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
@ -41,6 +42,12 @@ import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
*******************************************************************************/
public class AggregateAction
{
private static final QLogger LOG = QLogger.getLogger(AggregateAction.class);
private AggregateInterface aggregateInterface;
/*******************************************************************************
**
*******************************************************************************/
@ -56,7 +63,7 @@ public class AggregateAction
QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher();
QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(aggregateInput.getBackend());
AggregateInterface aggregateInterface = qModule.getAggregateInterface();
aggregateInterface = qModule.getAggregateInterface();
aggregateInterface.setQueryStat(queryStat);
AggregateOutput aggregateOutput = aggregateInterface.execute(aggregateInput);
@ -64,4 +71,20 @@ public class AggregateAction
return aggregateOutput;
}
/*******************************************************************************
**
*******************************************************************************/
public void cancel()
{
if(aggregateInterface == null)
{
LOG.warn("aggregateInterface object was null when requested to cancel");
return;
}
aggregateInterface.cancelAction();
}
}

View File

@ -26,6 +26,7 @@ import com.kingsrook.qqq.backend.core.actions.ActionHelper;
import com.kingsrook.qqq.backend.core.actions.interfaces.CountInterface;
import com.kingsrook.qqq.backend.core.actions.tables.helpers.QueryStatManager;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
@ -41,6 +42,12 @@ import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
*******************************************************************************/
public class CountAction
{
private static final QLogger LOG = QLogger.getLogger(CountAction.class);
private CountInterface countInterface;
/*******************************************************************************
**
*******************************************************************************/
@ -56,7 +63,7 @@ public class CountAction
QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher();
QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(countInput.getBackend());
CountInterface countInterface = qModule.getCountInterface();
countInterface = qModule.getCountInterface();
countInterface.setQueryStat(queryStat);
CountOutput countOutput = countInterface.execute(countInput);
@ -64,4 +71,20 @@ public class CountAction
return countOutput;
}
/*******************************************************************************
**
*******************************************************************************/
public void cancel()
{
if(countInterface == null)
{
LOG.warn("countInterface object was null when requested to cancel");
return;
}
countInterface.cancelAction();
}
}

View File

@ -76,6 +76,7 @@ public class QueryAction
private Optional<AbstractPostQueryCustomizer> postQueryRecordCustomizer;
private QueryInput queryInput;
private QueryInterface queryInterface;
private QPossibleValueTranslator qPossibleValueTranslator;
@ -121,7 +122,7 @@ public class QueryAction
QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher();
QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(backend);
QueryInterface queryInterface = qModule.getQueryInterface();
queryInterface = qModule.getQueryInterface();
queryInterface.setQueryStat(queryStat);
QueryOutput queryOutput = queryInterface.execute(queryInput);
@ -339,4 +340,20 @@ public class QueryAction
}
}
}
/*******************************************************************************
**
*******************************************************************************/
public void cancel()
{
if(queryInterface == null)
{
LOG.warn("queryInterface object was null when requested to cancel");
return;
}
queryInterface.cancelAction();
}
}

View File

@ -0,0 +1,109 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.actions.tables.helpers;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/*******************************************************************************
** For actions that may want to set a timeout, and cancel themselves if they run
** too long - this class helps.
**
** Construct with the timeout (delay & timeUnit), and a runnable that takes care
** of doing the cancel (e.g., cancelling a JDBC statement).
**
** Call start() to make a future get scheduled (note, if delay was null or <= 0,
** then it doesn't get scheduled at all).
**
** Call cancel() if the action got far enough/completed, to cancel the future.
**
** You can check didTimeout (getDidTimeout()) to know if the timeout did occur.
*******************************************************************************/
public class ActionTimeoutHelper
{
private final Integer delay;
private final TimeUnit timeUnit;
private final Runnable runnable;
private ScheduledFuture<?> future;
private boolean didTimeout = false;
/*******************************************************************************
** Constructor
**
*******************************************************************************/
public ActionTimeoutHelper(Integer delay, TimeUnit timeUnit, Runnable runnable)
{
this.delay = delay;
this.timeUnit = timeUnit;
this.runnable = runnable;
}
/*******************************************************************************
**
*******************************************************************************/
public void start()
{
if(delay == null || delay <= 0)
{
return;
}
future = Executors.newSingleThreadScheduledExecutor().schedule(() ->
{
didTimeout = true;
runnable.run();
}, delay, timeUnit);
}
/*******************************************************************************
**
*******************************************************************************/
public void cancel()
{
if(future != null)
{
future.cancel(true);
}
}
/*******************************************************************************
** Getter for didTimeout
**
*******************************************************************************/
public boolean getDidTimeout()
{
return didTimeout;
}
}

View File

@ -40,6 +40,8 @@ public class AggregateInput extends AbstractTableActionInput
private List<GroupBy> groupBys = new ArrayList<>();
private Integer limit;
private Integer timeoutSeconds;
private List<QueryJoin> queryJoins = null;
@ -269,4 +271,35 @@ public class AggregateInput extends AbstractTableActionInput
return (this);
}
/*******************************************************************************
** Getter for timeoutSeconds
*******************************************************************************/
public Integer getTimeoutSeconds()
{
return (this.timeoutSeconds);
}
/*******************************************************************************
** Setter for timeoutSeconds
*******************************************************************************/
public void setTimeoutSeconds(Integer timeoutSeconds)
{
this.timeoutSeconds = timeoutSeconds;
}
/*******************************************************************************
** Fluent setter for timeoutSeconds
*******************************************************************************/
public AggregateInput withTimeoutSeconds(Integer timeoutSeconds)
{
this.timeoutSeconds = timeoutSeconds;
return (this);
}
}

View File

@ -37,6 +37,8 @@ public class CountInput extends AbstractTableActionInput
{
private QQueryFilter filter;
private Integer timeoutSeconds;
private List<QueryJoin> queryJoins = null;
private Boolean includeDistinctCount = false;
@ -174,4 +176,35 @@ public class CountInput extends AbstractTableActionInput
return (this);
}
/*******************************************************************************
** Getter for timeoutSeconds
*******************************************************************************/
public Integer getTimeoutSeconds()
{
return (this.timeoutSeconds);
}
/*******************************************************************************
** Setter for timeoutSeconds
*******************************************************************************/
public void setTimeoutSeconds(Integer timeoutSeconds)
{
this.timeoutSeconds = timeoutSeconds;
}
/*******************************************************************************
** Fluent setter for timeoutSeconds
*******************************************************************************/
public CountInput withTimeoutSeconds(Integer timeoutSeconds)
{
this.timeoutSeconds = timeoutSeconds;
return (this);
}
}

View File

@ -42,6 +42,7 @@ public class QueryInput extends AbstractTableActionInput implements QueryOrGetIn
private QQueryFilter filter;
private RecordPipe recordPipe;
private Integer timeoutSeconds;
private boolean shouldTranslatePossibleValues = false;
private boolean shouldGenerateDisplayValues = false;
@ -537,4 +538,35 @@ public class QueryInput extends AbstractTableActionInput implements QueryOrGetIn
return (this);
}
/*******************************************************************************
** Getter for timeoutSeconds
*******************************************************************************/
public Integer getTimeoutSeconds()
{
return (this.timeoutSeconds);
}
/*******************************************************************************
** Setter for timeoutSeconds
*******************************************************************************/
public void setTimeoutSeconds(Integer timeoutSeconds)
{
this.timeoutSeconds = timeoutSeconds;
}
/*******************************************************************************
** Fluent setter for timeoutSeconds
*******************************************************************************/
public QueryInput withTimeoutSeconds(Integer timeoutSeconds)
{
this.timeoutSeconds = timeoutSeconds;
return (this);
}
}

View File

@ -24,6 +24,7 @@ package com.kingsrook.qqq.backend.module.rdbms.actions;
import java.io.Serializable;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
@ -91,6 +92,9 @@ public abstract class AbstractRDBMSAction implements QActionInterface
protected QueryStat queryStat;
protected PreparedStatement statement;
protected boolean isCancelled = false;
/*******************************************************************************
@ -1094,4 +1098,28 @@ public abstract class AbstractRDBMSAction implements QActionInterface
this.queryStat = queryStat;
}
/*******************************************************************************
**
*******************************************************************************/
protected void doCancelQuery()
{
isCancelled = true;
if(statement == null)
{
LOG.warn("Statement was null when requested to cancel query");
return;
}
try
{
statement.cancel();
}
catch(SQLException e)
{
LOG.warn("Error trying to cancel query (statement)", e);
}
}
}

View File

@ -27,8 +27,11 @@ import java.sql.Connection;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qqq.backend.core.actions.interfaces.AggregateInterface;
import com.kingsrook.qqq.backend.core.actions.tables.helpers.ActionTimeoutHelper;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
@ -53,6 +56,7 @@ public class RDBMSAggregateAction extends AbstractRDBMSAction implements Aggrega
{
private static final QLogger LOG = QLogger.getLogger(RDBMSAggregateAction.class);
private ActionTimeoutHelper actionTimeoutHelper;
/*******************************************************************************
@ -102,8 +106,21 @@ public class RDBMSAggregateAction extends AbstractRDBMSAction implements Aggrega
try(Connection connection = getConnection(aggregateInput))
{
QueryManager.executeStatement(connection, sql, ((ResultSet resultSet) ->
statement = connection.prepareStatement(sql);
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// set up & start an actionTimeoutHelper (note, internally it'll deal with the time being null or negative as meaning not to timeout) //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
actionTimeoutHelper = new ActionTimeoutHelper(aggregateInput.getTimeoutSeconds(), TimeUnit.SECONDS, new StatementTimeoutCanceller(statement, sql));
actionTimeoutHelper.start();
QueryManager.executeStatement(statement, ((ResultSet resultSet) ->
{
/////////////////////////////////////////////////////////////////////////
// once we've started getting results, go ahead and cancel the timeout //
/////////////////////////////////////////////////////////////////////////
actionTimeoutHelper.cancel();
while(resultSet.next())
{
setQueryStatFirstResultTime();
@ -156,9 +173,30 @@ public class RDBMSAggregateAction extends AbstractRDBMSAction implements Aggrega
}
catch(Exception e)
{
if(actionTimeoutHelper != null && actionTimeoutHelper.getDidTimeout())
{
setQueryStatFirstResultTime();
throw (new QUserFacingException("Aggregate query timed out."));
}
if(isCancelled)
{
throw (new QUserFacingException("Aggregate query was cancelled."));
}
LOG.warn("Error executing aggregate", e);
throw new QException("Error executing aggregate", e);
}
finally
{
if(actionTimeoutHelper != null)
{
/////////////////////////////////////////
// make sure the timeout got cancelled //
/////////////////////////////////////////
actionTimeoutHelper.cancel();
}
}
}
@ -199,4 +237,15 @@ public class RDBMSAggregateAction extends AbstractRDBMSAction implements Aggrega
return (StringUtils.join(",", columns));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void cancelAction()
{
doCancelQuery();
}
}

View File

@ -27,8 +27,11 @@ import java.sql.Connection;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qqq.backend.core.actions.interfaces.CountInterface;
import com.kingsrook.qqq.backend.core.actions.tables.helpers.ActionTimeoutHelper;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput;
@ -46,6 +49,8 @@ public class RDBMSCountAction extends AbstractRDBMSAction implements CountInterf
{
private static final QLogger LOG = QLogger.getLogger(RDBMSCountAction.class);
private ActionTimeoutHelper actionTimeoutHelper;
/*******************************************************************************
@ -84,8 +89,21 @@ public class RDBMSCountAction extends AbstractRDBMSAction implements CountInterf
{
long mark = System.currentTimeMillis();
QueryManager.executeStatement(connection, sql, ((ResultSet resultSet) ->
statement = connection.prepareStatement(sql);
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// set up & start an actionTimeoutHelper (note, internally it'll deal with the time being null or negative as meaning not to timeout) //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
actionTimeoutHelper = new ActionTimeoutHelper(countInput.getTimeoutSeconds(), TimeUnit.SECONDS, new StatementTimeoutCanceller(statement, sql));
actionTimeoutHelper.start();
QueryManager.executeStatement(statement, ((ResultSet resultSet) ->
{
/////////////////////////////////////////////////////////////////////////
// once we've started getting results, go ahead and cancel the timeout //
/////////////////////////////////////////////////////////////////////////
actionTimeoutHelper.cancel();
if(resultSet.next())
{
rs.setCount(resultSet.getInt("record_count"));
@ -107,9 +125,41 @@ public class RDBMSCountAction extends AbstractRDBMSAction implements CountInterf
}
catch(Exception e)
{
if(actionTimeoutHelper != null && actionTimeoutHelper.getDidTimeout())
{
setQueryStatFirstResultTime();
throw (new QUserFacingException("Count timed out."));
}
if(isCancelled)
{
throw (new QUserFacingException("Count was cancelled."));
}
LOG.warn("Error executing count", e);
throw new QException("Error executing count", e);
}
finally
{
if(actionTimeoutHelper != null)
{
/////////////////////////////////////////
// make sure the timeout got cancelled //
/////////////////////////////////////////
actionTimeoutHelper.cancel();
}
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void cancelAction()
{
doCancelQuery();
}
}

View File

@ -33,9 +33,12 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import com.kingsrook.qqq.backend.core.actions.interfaces.QueryInterface;
import com.kingsrook.qqq.backend.core.actions.tables.helpers.ActionTimeoutHelper;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.JoinsContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
@ -60,6 +63,8 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf
{
private static final QLogger LOG = QLogger.getLogger(RDBMSQueryAction.class);
private ActionTimeoutHelper actionTimeoutHelper;
/*******************************************************************************
@ -136,14 +141,29 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf
try
{
/////////////////////////////////////
// create a statement from the SQL //
/////////////////////////////////////
statement = createStatement(connection, sql.toString(), queryInput);
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// set up & start an actionTimeoutHelper (note, internally it'll deal with the time being null or negative as meaning not to timeout) //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
actionTimeoutHelper = new ActionTimeoutHelper(queryInput.getTimeoutSeconds(), TimeUnit.SECONDS, new StatementTimeoutCanceller(statement, sql));
actionTimeoutHelper.start();
//////////////////////////////////////////////
// execute the query - iterate over results //
//////////////////////////////////////////////
QueryOutput queryOutput = new QueryOutput(queryInput);
PreparedStatement statement = createStatement(connection, sql.toString(), queryInput);
QueryManager.executeStatement(statement, ((ResultSet resultSet) ->
{
/////////////////////////////////////////////////////////////////////////
// once we've started getting results, go ahead and cancel the timeout //
/////////////////////////////////////////////////////////////////////////
actionTimeoutHelper.cancel();
ResultSetMetaData metaData = resultSet.getMetaData();
while(resultSet.next())
{
@ -201,6 +221,14 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf
}
finally
{
if(actionTimeoutHelper != null)
{
/////////////////////////////////////////
// make sure the timeout got cancelled //
/////////////////////////////////////////
actionTimeoutHelper.cancel();
}
if(needToCloseConnection)
{
connection.close();
@ -209,6 +237,17 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf
}
catch(Exception e)
{
if(actionTimeoutHelper != null && actionTimeoutHelper.getDidTimeout())
{
setQueryStatFirstResultTime();
throw (new QUserFacingException("Query timed out."));
}
if(isCancelled)
{
throw (new QUserFacingException("Query was cancelled."));
}
LOG.warn("Error executing query", e);
throw new QException("Error executing query", e);
}
@ -282,20 +321,6 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf
/*******************************************************************************
**
*******************************************************************************/
private boolean filterOutHeavyFieldsIfNeeded(QFieldMetaData field, boolean shouldFetchHeavyFields)
{
if(!shouldFetchHeavyFields && field.getIsHeavy())
{
return (false);
}
return (true);
}
/*******************************************************************************
** if we're not fetching heavy fields, instead just get their length. this
** method wraps the field 'sql name' (e.g., column_name or table_name.column_name)
@ -338,4 +363,14 @@ public class RDBMSQueryAction extends AbstractRDBMSAction implements QueryInterf
return (statement);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void cancelAction()
{
doCancelQuery();
}
}

View File

@ -1075,6 +1075,7 @@ public class QJavalinImplementation
countInput.setFilter(JsonUtils.toObject(filter, QQueryFilter.class));
}
countInput.setTimeoutSeconds(DEFAULT_COUNT_TIMEOUT_SECONDS);
countInput.setQueryJoins(processQueryJoinsParam(context));
countInput.setIncludeDistinctCount(QJavalinUtils.queryParamIsTrue(context, "includeDistinct"));
@ -1131,6 +1132,7 @@ public class QJavalinImplementation
queryInput.setTableName(table);
queryInput.setShouldGenerateDisplayValues(true);
queryInput.setShouldTranslatePossibleValues(true);
queryInput.setTimeoutSeconds(DEFAULT_QUERY_TIMEOUT_SECONDS);
PermissionsHelper.checkTablePermissionThrowing(queryInput, TablePermissionSubType.READ);