Standard QQQ garbage collector process

This commit is contained in:
2023-10-16 08:12:39 -05:00
parent 61a6c0e8b0
commit 7339ad90cc
4 changed files with 795 additions and 0 deletions

View File

@ -0,0 +1,60 @@
/*
* 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.processes.implementations.garbagecollector;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import com.kingsrook.qqq.backend.core.utils.collections.ListBuilder;
/*******************************************************************************
**
*******************************************************************************/
public class GarbageCollectorExtractStep extends ExtractViaQueryStep
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected QQueryFilter getQueryFilter(RunBackendStepInput runBackendStepInput) throws QException
{
//////////////////////////////////////////////////////////////////////////////////////////
// in case the process was executed via a frontend, and the user specified a limitDate, //
// then put that date in the defaultQueryFilter, rather than the default //
//////////////////////////////////////////////////////////////////////////////////////////
Instant limitDate = ValueUtils.getValueAsInstant(runBackendStepInput.getValue("limitDate"));
if(limitDate != null)
{
QQueryFilter defaultQueryFilter = (QQueryFilter) runBackendStepInput.getValue("defaultQueryFilter");
defaultQueryFilter.getCriteria().get(0).setValues(ListBuilder.of(limitDate));
}
return super.getQueryFilter(runBackendStepInput);
}
}

View File

@ -0,0 +1,107 @@
/*
* 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.processes.implementations.garbagecollector;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.LoadViaDeleteStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.LESS_THAN;
/*******************************************************************************
** Create a garbage collector process for a given table.
**
** Process will be named: tableName + "GarbageCollector"
**
** It requires a dateTime field which is used in the query to find old records
** to be deleted. This dateTime field is, by default, compared with the input
** 'nowWithOffset' (e.g., .minus(30, DAYS)).
**
** Child join tables can also be GC'ed. This behavior is controlled via the
** joinedTablesToAlsoDelete parameter, which behaves as follows:
** - if the value is "*", then ALL descendent joins are GC'ed from.
** - if the value is null, then NO descendent joins are GC'ed from.
** - else the value is split on commas, and only table names found in the split are GC'ed.
**
** The process is, by default, associated with its associated table, so it can
** show up in UI's if permissed as such. When ran in a UI, it presents a limitDate
** field, which users can use to override the default limit.
**
** It does not get a schedule by default.
**
*******************************************************************************/
public class GarbageCollectorProcessMetaDataProducer
{
/*******************************************************************************
** See class header for param descriptions.
*******************************************************************************/
public static QProcessMetaData createProcess(String tableName, String dateTimeField, NowWithOffset nowWithOffset, String joinedTablesToAlsoDelete)
{
QProcessMetaData processMetaData = StreamedETLWithFrontendProcess.processMetaDataBuilder()
.withName(tableName + "GarbageCollector")
.withIcon(new QIcon().withName("auto_delete"))
.withTableName(tableName)
.withSourceTable(tableName)
.withDestinationTable(tableName)
.withExtractStepClass(GarbageCollectorExtractStep.class)
.withTransformStepClass(GarbageCollectorTransformStep.class)
.withLoadStepClass(LoadViaDeleteStep.class)
.withTransactionLevelPage()
.withPreviewMessage(StreamedETLWithFrontendProcess.DEFAULT_PREVIEW_MESSAGE_FOR_DELETE)
.withReviewStepRecordFields(List.of(
new QFieldMetaData("id", QFieldType.INTEGER),
new QFieldMetaData(dateTimeField, QFieldType.DATE_TIME)
))
.withDefaultQueryFilter(new QQueryFilter(new QFilterCriteria(dateTimeField, LESS_THAN, nowWithOffset)))
.getProcessMetaData();
processMetaData.getBackendStep(StreamedETLWithFrontendProcess.STEP_NAME_VALIDATE)
.withInputData(new QFunctionInputMetaData()
.withField(new QFieldMetaData("joinedTablesToAlsoDelete", QFieldType.STRING).withDefaultValue(joinedTablesToAlsoDelete)));
processMetaData.addStep(0, new QFrontendStepMetaData()
.withName("input")
.withLabel("Input")
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.HELP_TEXT).withValue("text", """
You can specify a limit date, or let the system use its default.
"""))
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
.withFormField(new QFieldMetaData("limitDate", QFieldType.DATE_TIME))
);
return (processMetaData);
}
}

View File

@ -0,0 +1,300 @@
/*
* 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.processes.implementations.garbagecollector;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.metadata.JoinGraph;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunInput;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.BackendStepPostRunOutput;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
import static com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.IN;
/*******************************************************************************
**
*******************************************************************************/
public class GarbageCollectorTransformStep extends AbstractTransformStep
{
private static final QLogger LOG = QLogger.getLogger(GarbageCollectorTransformStep.class);
private int count = 0;
private int total = 0;
private final ProcessSummaryLine okSummary = new ProcessSummaryLine(Status.OK)
.withMessageSuffix(" deleted")
.withSingularFutureMessage("will be")
.withPluralFutureMessage("will be")
.withSingularPastMessage("has been")
.withPluralPastMessage("have been");
private Map<String, Integer> descendantRecordCountToDelete = new LinkedHashMap<>();
/*******************************************************************************
** getProcessSummary
*
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
okSummary.addSelfToListIfAnyCount(rs);
for(Map.Entry<String, Integer> entry : descendantRecordCountToDelete.entrySet())
{
ProcessSummaryLine childSummary = new ProcessSummaryLine(Status.OK)
.withMessageSuffix(" deleted")
.withSingularFutureMessage("associated " + entry.getKey() + " record will be")
.withPluralFutureMessage("associated " + entry.getKey() + " records will be")
.withSingularPastMessage("associated " + entry.getKey() + " record has been")
.withPluralPastMessage("associated " + entry.getKey() + " records have been");
childSummary.setCount(entry.getValue());
rs.add(childSummary);
}
if(total == 0)
{
rs.add(new ProcessSummaryLine(Status.INFO, null, "No records were found to be garbage collected."));
}
return (rs);
}
/*******************************************************************************
** run
*
*******************************************************************************/
@Override
public void run(RunBackendStepInput runBackendStepInput, RunBackendStepOutput runBackendStepOutput) throws QException
{
////////////////////////////////
// return if no input records //
////////////////////////////////
if(CollectionUtils.nullSafeIsEmpty(runBackendStepInput.getRecords()))
{
return;
}
///////////////////////////////////////////////////////////////////
// keep a count (in case table doesn't support count capacility) //
///////////////////////////////////////////////////////////////////
count += runBackendStepInput.getRecords().size();
total = Objects.requireNonNullElse(runBackendStepInput.getValueInteger(StreamedETLWithFrontendProcess.FIELD_RECORD_COUNT), count);
runBackendStepInput.getAsyncJobCallback().updateStatus("Validating records", count, total);
////////////////////////////////////////////////////////////////////////////////////////
// process the joinedTablesToAlsoDelete value. //
// if it's "*", interpret that as all tables in the instance. //
// else split it on commas. //
// note that absent value or empty string means we won't delete from any other tables //
////////////////////////////////////////////////////////////////////////////////////////
String joinedTablesToAlsoDelete = runBackendStepInput.getValueString("joinedTablesToAlsoDelete");
Set<String> setOfJoinedTablesToAlsoDelete = new HashSet<>();
if("*".equals(joinedTablesToAlsoDelete))
{
setOfJoinedTablesToAlsoDelete.addAll(QContext.getQInstance().getTables().keySet());
}
else if(joinedTablesToAlsoDelete != null)
{
setOfJoinedTablesToAlsoDelete.addAll(Arrays.asList(joinedTablesToAlsoDelete.split(",")));
}
///////////////////
// process joins //
///////////////////
String tableName = runBackendStepInput.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE);
lookForJoins(runBackendStepInput, tableName, runBackendStepInput.getRecords(), new HashSet<>(Set.of(tableName)), setOfJoinedTablesToAlsoDelete);
LOG.info("GarbageCollector called with a page of records", logPair("count", runBackendStepInput.getRecords().size()), logPair("table", tableName));
////////////////////////////////////////////////////
// move records (from primary table) to next step //
////////////////////////////////////////////////////
for(QRecord qRecord : runBackendStepInput.getRecords())
{
okSummary.incrementCountAndAddPrimaryKey(qRecord.getValue(runBackendStepInput.getTable().getPrimaryKeyField()));
runBackendStepOutput.getRecords().add(qRecord);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void postRun(BackendStepPostRunInput runBackendStepInput, BackendStepPostRunOutput runBackendStepOutput) throws QException
{
super.postRun(runBackendStepInput, runBackendStepOutput);
///////////////////////////////////////////////////////////////////////////////////////
// if we've just finished the validate step - //
// and if there wasn't a COUNT performed (e.g., because the table didn't support it) //
// then set our total that we accumulated into the count field. //
///////////////////////////////////////////////////////////////////////////////////////
if(runBackendStepInput.getStepName().equals(StreamedETLWithFrontendProcess.STEP_NAME_VALIDATE))
{
if(runBackendStepInput.getValueInteger(StreamedETLWithFrontendProcess.FIELD_RECORD_COUNT) == null)
{
runBackendStepInput.addValue(StreamedETLWithFrontendProcess.FIELD_RECORD_COUNT, total);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private void lookForJoins(RunBackendStepInput runBackendStepInput, String tableName, List<QRecord> records, Set<String> visitedTables, Set<String> allowedToAlsoDelete) throws QException
{
////////////////////////////////////////////////////////////////////////////////////////
// if we've already visited all the tables we're allowed to delete, then return early //
////////////////////////////////////////////////////////////////////////////////////////
HashSet<String> anyAllowedLeft = new HashSet<>(allowedToAlsoDelete);
anyAllowedLeft.removeAll(visitedTables);
if(CollectionUtils.nullSafeIsEmpty(anyAllowedLeft))
{
return;
}
QInstance qInstance = QContext.getQInstance();
JoinGraph joinGraph = qInstance.getJoinGraph();
////////////////////////////////////////////////////////////////////
// get join connections from this table from the joinGraph object //
////////////////////////////////////////////////////////////////////
Set<JoinGraph.JoinConnectionList> joinConnections = joinGraph.getJoinConnections(tableName);
for(JoinGraph.JoinConnectionList joinConnectionList : CollectionUtils.nonNullCollection(joinConnections))
{
List<JoinGraph.JoinConnection> list = joinConnectionList.list();
JoinGraph.JoinConnection joinConnection = list.get(0);
QJoinMetaData join = qInstance.getJoin(joinConnection.viaJoinName());
String recurOnTable = null;
String thisTableFKeyField = null;
String joinTablePrimaryKeyField = null;
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// find the input table in the join - but only if it's on a '1' side of the join (not a many side) //
// this means we may get out of this if/else with recurOnTable = null, if we shouldn't process this join. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(join.getLeftTable().equals(tableName) && (join.getType().equals(JoinType.ONE_TO_MANY) || join.getType().equals(JoinType.ONE_TO_ONE)))
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if this table is on the left side of this join, and it's a 1-n or 1-1, then delete from the right table //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
recurOnTable = join.getRightTable();
thisTableFKeyField = join.getJoinOns().get(0).getLeftField();
joinTablePrimaryKeyField = join.getJoinOns().get(0).getRightField();
}
else if(join.getRightTable().equals(tableName) && (join.getType().equals(JoinType.MANY_TO_ONE) || join.getType().equals(JoinType.ONE_TO_ONE)))
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else if this table is on the right side of this join, and it's n-1 or 1-1, then delete from the left table //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
recurOnTable = join.getLeftTable();
thisTableFKeyField = join.getJoinOns().get(0).getRightField();
joinTablePrimaryKeyField = join.getJoinOns().get(0).getLeftField();
}
//////////////////////////////////////////////////////////////////////////////////////////////
// if we found a table to 'recur' on, and we haven't visited it before, then process it now //
//////////////////////////////////////////////////////////////////////////////////////////////
if(recurOnTable != null && !visitedTables.contains(recurOnTable))
{
if(join.getJoinOns().size() > 1)
{
LOG.warn("We would delete child records from the join [" + join.getName() + "], but it has multiple joinOns, and we don't support that yet...");
continue;
}
visitedTables.add(recurOnTable);
////////////////////////////////////////////////////////////
// query for records in the child table based on the join //
////////////////////////////////////////////////////////////
QTableMetaData foreignTable = qInstance.getTable(recurOnTable);
String finalThisTableFKeyField = thisTableFKeyField;
List<Serializable> foreignKeys = records.stream().map(r -> r.getValue(finalThisTableFKeyField)).distinct().toList();
List<QRecord> foreignRecords = new QueryAction().execute(new QueryInput(recurOnTable).withFilter(new QQueryFilter(new QFilterCriteria(joinTablePrimaryKeyField, IN, foreignKeys)))).getRecords();
////////////////////////////////////////////////////////////////////////////////////
// make a recursive call looking for children of this table //
// we do this before we delete from this table, so that the children can be found //
////////////////////////////////////////////////////////////////////////////////////
lookForJoins(runBackendStepInput, recurOnTable, foreignRecords, visitedTables, allowedToAlsoDelete);
if(allowedToAlsoDelete.contains(recurOnTable))
{
LOG.info("Deleting descendant records from: " + recurOnTable);
descendantRecordCountToDelete.putIfAbsent(foreignTable.getLabel(), 0);
descendantRecordCountToDelete.put(foreignTable.getLabel(), descendantRecordCountToDelete.get(foreignTable.getLabel()) + foreignRecords.size());
/////////////////////////////////////////////////////////////////////
// if this is the execute step - then do it - delete the children. //
/////////////////////////////////////////////////////////////////////
if(runBackendStepInput.getStepName().equals(StreamedETLWithFrontendProcess.STEP_NAME_EXECUTE))
{
List<Serializable> foreignPKeys = foreignRecords.stream().map(r -> r.getValue(foreignTable.getPrimaryKeyField())).toList();
new DeleteAction().execute(new DeleteInput(recurOnTable).withPrimaryKeys(foreignPKeys).withTransaction(getTransaction().orElse(null)));
}
}
}
}
}
}