This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new ff1951418e Add Caching Database execution information location, fixes
#2207 (#7709)
ff1951418e is described below
commit ff1951418e63720ab9096ee7cc493da529bcbdc1
Author: Matt Casters <[email protected]>
AuthorDate: Fri Jul 31 09:46:15 2026 +0200
Add Caching Database execution information location, fixes #2207 (#7709)
Store aggregated CacheEntry JSON in a single relational table with
denormalized filter columns and indexes, reusing
BaseCachingExecutionInfoLocation.
Includes Show DDL button for dialect-specific CREATE TABLE/INDEX statements.
---
assemblies/plugins/pom.xml | 6 +
.../execution-information-location.adoc | 24 +
plugins/misc/{ => execution-database}/pom.xml | 48 +-
.../execution-database/src/assembly/assembly.xml | 37 ++
.../CachingDatabaseExecutionInfoLocation.java | 734 +++++++++++++++++++++
.../database/messages/messages_en_US.properties | 27 +
.../src/main/resources/version.xml | 19 +
.../CachingDatabaseExecutionInfoLocationTest.java | 295 +++++++++
plugins/misc/pom.xml | 1 +
9 files changed, 1171 insertions(+), 20 deletions(-)
diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index af5781ff32..5198265e75 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -632,6 +632,12 @@
<type>zip</type>
</dependency>
<!-- marketplace optional: hop-misc-documentation -->
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-misc-execution-database</artifactId>
+ <version>${project.version}</version>
+ <type>zip</type>
+ </dependency>
<dependency>
<groupId>org.apache.hop</groupId>
<artifactId>hop-misc-git</artifactId>
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/execution-information-location.adoc
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/execution-information-location.adoc
index 3a05175dd2..e34ce38d6d 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/execution-information-location.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/execution-information-location.adoc
@@ -91,6 +91,30 @@ With this location type you can store execution information
in a xref:technology
The Elastic location type stores information in an Elastic index. See the
xref:metadata-types/elastic-location-type.adoc[Elastic location type]
+=== Caching Database location
+
+Like the Caching File location, execution information is aggregated in memory
and then persisted as one document per pipeline or workflow.
+Instead of writing JSON files, this location stores the same JSON payload in a
**single relational table**, together with a small set of filter columns so the
Execution Information perspective can query efficiently without parsing the
CLOB on every list request.
+
+This deliberately avoids the old multi-table 3NF logging schema used in
Kettle: one table, dialect-agnostic DDL generated from Hop's database layer,
and no per-dialect schema maintenance for nested execution details.
+
+Options:
+
+* *Relational database connection*: the Hop relational connection to use
+* *Schema name (optional)*: optional schema for the table
+* *Table name*: defaults to `hop_executions`
+* *Persistence delay*: maximum time to wait before persisting dirty cache
entries (ms)
+* *Maximum cache age*: maximum time to keep an entry in memory without
read/write (ms)
+* *Show table and indexes DDL*: generates dialect-specific `CREATE TABLE` and
`CREATE INDEX` statements for the selected connection (does not execute them).
Run the DDL on your database before writing execution information.
+
+Table layout (logical):
+
+* Primary key `id` (log channel / execution id)
+* Filter columns: `name`, `execution_type`, `parent_id`, `registration_date`,
`execution_start_date`, `execution_end_date`, `failed`, `status_description`,
`duration_ms`
+* `json`: full `CacheEntry` JSON (execution, state, children, samples, logging
text, …)
+
+Indexes are recommended on the filter columns used by the GUI (start date,
name, type, failed, parent id, status). Use the **Show table and indexes DDL**
button to obtain statements for your database dialect.
+
=== Cleanup
If you want to automate removal of execution information from your graph
database you can use a pipeline or workflow to do so.
diff --git a/plugins/misc/pom.xml b/plugins/misc/execution-database/pom.xml
similarity index 55%
copy from plugins/misc/pom.xml
copy to plugins/misc/execution-database/pom.xml
index 8af8f52b7a..171d6d0b00 100644
--- a/plugins/misc/pom.xml
+++ b/plugins/misc/execution-database/pom.xml
@@ -20,29 +20,37 @@
<parent>
<groupId>org.apache.hop</groupId>
- <artifactId>hop-plugins</artifactId>
+ <artifactId>hop-plugins-misc</artifactId>
<version>2.19.0-SNAPSHOT</version>
</parent>
- <artifactId>hop-plugins-misc</artifactId>
- <packaging>pom</packaging>
- <name>Hop Plugins Miscellaneous</name>
+ <artifactId>hop-misc-execution-database</artifactId>
+ <packaging>jar</packaging>
+ <name>Hop Plugins Misc Execution Database Location</name>
- <modules>
- <module>async</module>
- <module>debug</module>
- <module>documentation</module>
- <module>git</module>
- <module>import</module>
- <module>mail</module>
- <module>marketplace</module>
- <module>passwords</module>
- <module>projects</module>
- <module>py4j</module>
- <module>reflection</module>
- <module>rest</module>
- <module>static-schema</module>
- <module>testing</module>
- </modules>
+ <dependencyManagement>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-libs-jdbc</artifactId>
+ <version>${project.version}</version>
+ <type>pom</type>
+ <scope>import</scope>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
+ <dependencies>
+ <dependency>
+ <groupId>com.h2database</groupId>
+ <artifactId>h2</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-databases-h2</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
</project>
diff --git a/plugins/misc/execution-database/src/assembly/assembly.xml
b/plugins/misc/execution-database/src/assembly/assembly.xml
new file mode 100644
index 0000000000..c284b752d6
--- /dev/null
+++ b/plugins/misc/execution-database/src/assembly/assembly.xml
@@ -0,0 +1,37 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+
+<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0
http://maven.apache.org/xsd/assembly-2.2.0.xsd">
+ <id>hop-misc-execution-database</id>
+ <formats>
+ <format>zip</format>
+ </formats>
+ <baseDirectory>.</baseDirectory>
+ <files>
+ <file>
+ <source>${project.basedir}/src/main/resources/version.xml</source>
+ <outputDirectory>${hop.plugin.libdir}</outputDirectory>
+ <filtered>true</filtered>
+ </file>
+ </files>
+
+ <componentDescriptors>
+
<componentDescriptor>${maven.multiModuleProjectDirectory}/assemblies/shared/hop-plugin-libs.xml</componentDescriptor>
+ </componentDescriptors>
+</assembly>
diff --git
a/plugins/misc/execution-database/src/main/java/org/apache/hop/execution/database/CachingDatabaseExecutionInfoLocation.java
b/plugins/misc/execution-database/src/main/java/org/apache/hop/execution/database/CachingDatabaseExecutionInfoLocation.java
new file mode 100644
index 0000000000..b6c413b295
--- /dev/null
+++
b/plugins/misc/execution-database/src/main/java/org/apache/hop/execution/database/CachingDatabaseExecutionInfoLocation.java
@@ -0,0 +1,734 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.execution.database;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.sql.ResultSet;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.RowMetaAndData;
+import org.apache.hop.core.database.Database;
+import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.logging.LoggingObject;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaBoolean;
+import org.apache.hop.core.row.value.ValueMetaDate;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.execution.Execution;
+import org.apache.hop.execution.ExecutionInfoLocation;
+import org.apache.hop.execution.ExecutionState;
+import org.apache.hop.execution.IExecutionInfoLocation;
+import org.apache.hop.execution.IExecutionSelector;
+import org.apache.hop.execution.LastPeriod;
+import org.apache.hop.execution.caching.BaseCachingExecutionInfoLocation;
+import org.apache.hop.execution.caching.CacheEntry;
+import org.apache.hop.execution.caching.DatedId;
+import org.apache.hop.execution.plugin.ExecutionInfoLocationPlugin;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.ui.core.dialog.EnterTextDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.hopgui.HopGui;
+
+/**
+ * Caches execution information and persists each top-level pipeline/workflow
{@link CacheEntry} as
+ * one row in a relational table: filter columns for efficient queries, plus a
CLOB/TEXT column with
+ * the full JSON payload (same shape as Caching File / Elastic / OpenSearch).
+ */
+@GuiPlugin(description = "Caching Database execution information location GUI
elements")
+@ExecutionInfoLocationPlugin(
+ id = "caching-database-location",
+ name = "Caching Database location",
+ description =
+ "Aggregates and caches execution information before storing in a
relational database")
+@Getter
+@Setter
+public class CachingDatabaseExecutionInfoLocation extends
BaseCachingExecutionInfoLocation
+ implements IExecutionInfoLocation {
+
+ public static final Class<?> PKG =
CachingDatabaseExecutionInfoLocation.class;
+
+ public static final String PLUGIN_ID = "caching-database-location";
+ public static final String DEFAULT_TABLE_NAME = "hop_executions";
+
+ public static final String COL_ID = "id";
+ public static final String COL_NAME = "name";
+ public static final String COL_EXECUTION_TYPE = "execution_type";
+ public static final String COL_PARENT_ID = "parent_id";
+ public static final String COL_REGISTRATION_DATE = "registration_date";
+ public static final String COL_EXECUTION_START_DATE = "execution_start_date";
+ public static final String COL_EXECUTION_END_DATE = "execution_end_date";
+ public static final String COL_FAILED = "failed";
+ public static final String COL_STATUS_DESCRIPTION = "status_description";
+ public static final String COL_DURATION_MS = "duration_ms";
+ public static final String COL_JSON = "json";
+
+ @GuiWidgetElement(
+ id = "connectionName",
+ order = "010",
+ parentId = ExecutionInfoLocation.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ type = GuiElementType.METADATA,
+ metadata = DatabaseMeta.class,
+ toolTip =
"i18n::CachingDatabaseExecutionInfoLocation.Connection.Tooltip",
+ label = "i18n::CachingDatabaseExecutionInfoLocation.Connection.Label")
+ @HopMetadataProperty(key = "connection")
+ protected String connectionName;
+
+ @GuiWidgetElement(
+ id = "schemaName",
+ order = "020",
+ parentId = ExecutionInfoLocation.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ type = GuiElementType.TEXT,
+ toolTip =
"i18n::CachingDatabaseExecutionInfoLocation.SchemaName.Tooltip",
+ label = "i18n::CachingDatabaseExecutionInfoLocation.SchemaName.Label")
+ @HopMetadataProperty
+ protected String schemaName;
+
+ @GuiWidgetElement(
+ id = "tableName",
+ order = "030",
+ parentId = ExecutionInfoLocation.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ type = GuiElementType.TEXT,
+ toolTip = "i18n::CachingDatabaseExecutionInfoLocation.TableName.Tooltip",
+ label = "i18n::CachingDatabaseExecutionInfoLocation.TableName.Label")
+ @HopMetadataProperty
+ protected String tableName = DEFAULT_TABLE_NAME;
+
+ /** Loaded database metadata (from connection name or injected for tests). */
+ protected transient DatabaseMeta databaseMeta;
+
+ protected transient Database database;
+
+ protected String actualConnectionName;
+ protected String actualSchemaName;
+ protected String actualTableName;
+
+ public CachingDatabaseExecutionInfoLocation() {
+ super();
+ }
+
+ public
CachingDatabaseExecutionInfoLocation(CachingDatabaseExecutionInfoLocation
location) {
+ super(location);
+ this.connectionName = location.connectionName;
+ this.schemaName = location.schemaName;
+ this.tableName = location.tableName;
+ this.databaseMeta = location.databaseMeta;
+ this.actualConnectionName = location.actualConnectionName;
+ this.actualSchemaName = location.actualSchemaName;
+ this.actualTableName = location.actualTableName;
+ }
+
+ @Override
+ public CachingDatabaseExecutionInfoLocation clone() {
+ return new CachingDatabaseExecutionInfoLocation(this);
+ }
+
+ @Override
+ public void initialize(IVariables variables, IHopMetadataProvider
metadataProvider)
+ throws HopException {
+ this.variables = variables;
+ this.metadataProvider = metadataProvider;
+
+ actualConnectionName = variables != null ?
variables.resolve(connectionName) : connectionName;
+ actualSchemaName =
+ variables != null
+ ? variables.resolve(Const.NVL(schemaName, ""))
+ : Const.NVL(schemaName, "");
+ String resolvedTable =
+ variables != null ? variables.resolve(Const.NVL(tableName,
DEFAULT_TABLE_NAME)) : tableName;
+ actualTableName = StringUtils.isEmpty(resolvedTable) ? DEFAULT_TABLE_NAME
: resolvedTable;
+
+ if (databaseMeta == null) {
+ if (StringUtils.isEmpty(actualConnectionName)) {
+ throw new HopException(
+ "Caching database execution information location has no database
connection configured");
+ }
+ if (metadataProvider == null) {
+ throw new HopException(
+ "No metadata provider available to load database connection '"
+ + actualConnectionName
+ + "'");
+ }
+ try {
+ databaseMeta =
+
metadataProvider.getSerializer(DatabaseMeta.class).load(actualConnectionName);
+ } catch (Exception e) {
+ throw new HopException(
+ "Error loading database connection '" + actualConnectionName +
"'", e);
+ }
+ if (databaseMeta == null) {
+ throw new HopException(
+ "Database connection '" + actualConnectionName + "' could not be
found");
+ }
+ }
+
+ try {
+ database =
+ new Database(
+ new LoggingObject("CachingDatabaseExecutionInfoLocation"),
variables, databaseMeta);
+ database.connect();
+ } catch (Exception e) {
+ throw new HopException(
+ "Error connecting to database for execution information location
using connection '"
+ + Const.NVL(actualConnectionName, databaseMeta.getName())
+ + "'",
+ e);
+ }
+
+ super.initialize(variables, metadataProvider);
+ LogChannel.GENERAL.logBasic(
+ "Caching database execution info location ready: connection="
+ + Const.NVL(actualConnectionName, databaseMeta.getName())
+ + ", table="
+ + getQuotedSchemaTable());
+ }
+
+ @Override
+ public synchronized void close() throws HopException {
+ try {
+ super.close();
+ } finally {
+ if (database != null) {
+ try {
+ database.disconnect();
+ } catch (Exception e) {
+ LogChannel.GENERAL.logError(
+ "Error disconnecting database for execution information
location", e);
+ }
+ database = null;
+ }
+ }
+ }
+
+ @Override
+ protected void persistCacheEntry(CacheEntry cacheEntry) throws HopException {
+ try {
+ mergeChildrenFromDatabase(cacheEntry);
+ cacheEntry.calculateSummary();
+
+ ObjectMapper mapper = new ObjectMapper();
+ String json = mapper.writeValueAsString(cacheEntry);
+
+ IRowMeta rowMeta = createDataRowMeta();
+ Object[] data = buildRowData(cacheEntry, json);
+
+ synchronized (database) {
+ upsertCacheEntry(rowMeta, data);
+ }
+
+ cacheEntry.setDirty(false);
+ cacheEntry.setLastWritten(new Date());
+ } catch (Exception e) {
+ throw new HopException(
+ "Error writing cache entry to database table " +
getQuotedSchemaTable(), e);
+ }
+ }
+
+ /**
+ * Multi-writer safe: union child maps from the existing row so concurrent
processes do not wipe
+ * samples/children written by others (same idea as Caching File).
+ */
+ private void mergeChildrenFromDatabase(CacheEntry cacheEntry) {
+ if (cacheEntry == null || StringUtils.isEmpty(cacheEntry.getId())) {
+ return;
+ }
+ try {
+ CacheEntry existing = loadCacheEntry(cacheEntry.getId());
+ if (existing == null) {
+ return;
+ }
+ mergeMap(existing.getChildExecutions(), cacheEntry.getChildExecutions());
+ mergeMap(existing.getChildExecutionStates(),
cacheEntry.getChildExecutionStates());
+ mergeMap(existing.getChildExecutionData(),
cacheEntry.getChildExecutionData());
+ } catch (Exception e) {
+ LogChannel.GENERAL.logError(
+ "Unable to merge on-database cache entry before persist (non-fatal):
" + e.getMessage());
+ }
+ }
+
+ private static <K, V> void mergeMap(Map<K, V> fromStore, Map<K, V> into) {
+ if (fromStore == null || fromStore.isEmpty() || into == null) {
+ return;
+ }
+ for (Map.Entry<K, V> e : fromStore.entrySet()) {
+ into.putIfAbsent(e.getKey(), e.getValue());
+ }
+ }
+
+ private boolean rowExists(String id) throws HopException {
+ String sql =
+ "SELECT 1 FROM "
+ + getQuotedSchemaTable()
+ + " WHERE "
+ + databaseMeta.quoteField(COL_ID)
+ + " = ?";
+ IRowMeta paramMeta = new RowMeta();
+ paramMeta.addValueMeta(new ValueMetaString(COL_ID, 100, -1));
+ RowMetaAndData one = database.getOneRow(sql, paramMeta, new Object[] {id});
+ return one != null && one.getData() != null;
+ }
+
+ /** Upsert: if a row with the same id exists UPDATE, otherwise INSERT. */
+ private void upsertCacheEntry(IRowMeta rowMeta, Object[] data) throws
HopException {
+ String id = (String) data[0];
+ if (rowExists(id)) {
+ String[] setFields =
+ new String[] {
+ COL_NAME,
+ COL_EXECUTION_TYPE,
+ COL_PARENT_ID,
+ COL_REGISTRATION_DATE,
+ COL_EXECUTION_START_DATE,
+ COL_EXECUTION_END_DATE,
+ COL_FAILED,
+ COL_STATUS_DESCRIPTION,
+ COL_DURATION_MS,
+ COL_JSON
+ };
+ String[] codes = new String[] {COL_ID};
+ String[] conditions = new String[] {"="};
+
+ if (!database.prepareUpdate(
+ actualSchemaName, actualTableName, codes, conditions, setFields)) {
+ throw new HopException("Unable to prepare update for table " +
getQuotedSchemaTable());
+ }
+ try {
+ // prepareUpdate binds SET fields first, then WHERE values
+ Object[] updateData = new Object[setFields.length + 1];
+ updateData[0] = data[1];
+ updateData[1] = data[2];
+ updateData[2] = data[3];
+ updateData[3] = data[4];
+ updateData[4] = data[5];
+ updateData[5] = data[6];
+ updateData[6] = data[7];
+ updateData[7] = data[8];
+ updateData[8] = data[9];
+ updateData[9] = data[10];
+ updateData[10] = data[0];
+
+ IRowMeta updateMeta = new RowMeta();
+ for (String setField : setFields) {
+ updateMeta.addValueMeta(rowMeta.searchValueMeta(setField));
+ }
+ updateMeta.addValueMeta(rowMeta.searchValueMeta(COL_ID));
+
+ database.setValuesUpdate(updateMeta, updateData);
+ database.updateRow();
+ } finally {
+ database.closeUpdate();
+ }
+ } else {
+ database.insertRow(actualSchemaName, actualTableName, rowMeta, data);
+ }
+ }
+
+ @Override
+ protected CacheEntry loadCacheEntry(String executionId) throws HopException {
+ try {
+ String sql =
+ "SELECT "
+ + databaseMeta.quoteField(COL_JSON)
+ + " FROM "
+ + getQuotedSchemaTable()
+ + " WHERE "
+ + databaseMeta.quoteField(COL_ID)
+ + " = ?";
+ IRowMeta paramMeta = new RowMeta();
+ paramMeta.addValueMeta(new ValueMetaString(COL_ID, 100, -1));
+
+ synchronized (database) {
+ RowMetaAndData row = database.getOneRow(sql, paramMeta, new Object[]
{executionId});
+ if (row == null || row.getData() == null) {
+ return null;
+ }
+ Object jsonObj = row.getData()[0];
+ if (jsonObj == null) {
+ return null;
+ }
+ String json = jsonObj.toString();
+ ObjectMapper mapper = new ObjectMapper();
+ return mapper.readValue(json, CacheEntry.class);
+ }
+ } catch (Exception e) {
+ throw new HopException(
+ "Error loading execution information from database for executionId
'" + executionId + "'",
+ e);
+ }
+ }
+
+ @Override
+ public void deleteCacheEntry(CacheEntry cacheEntry) throws HopException {
+ if (cacheEntry == null || StringUtils.isEmpty(cacheEntry.getId())) {
+ return;
+ }
+ try {
+ String sql =
+ "DELETE FROM "
+ + getQuotedSchemaTable()
+ + " WHERE "
+ + databaseMeta.quoteField(COL_ID)
+ + " = ?";
+ IRowMeta paramMeta = new RowMeta();
+ paramMeta.addValueMeta(new ValueMetaString(COL_ID, 100, -1));
+ synchronized (database) {
+ database.execStatement(sql, paramMeta, new Object[]
{cacheEntry.getId()});
+ }
+ } catch (Exception e) {
+ throw new HopException(
+ "Error deleting execution information from database for id '" +
cacheEntry.getId() + "'",
+ e);
+ }
+ }
+
+ @Override
+ protected void retrieveIds(
+ boolean includeChildren, Set<DatedId> ids, int limit, IExecutionSelector
selector)
+ throws HopException {
+ final IExecutionSelector activeSelector = selector == null ?
IExecutionSelector.ALL : selector;
+ try {
+ StringBuilder sql = new StringBuilder();
+ sql.append("SELECT ")
+ .append(databaseMeta.quoteField(COL_ID))
+ .append(", ")
+ .append(databaseMeta.quoteField(COL_EXECUTION_START_DATE))
+ .append(" FROM ")
+ .append(getQuotedSchemaTable());
+
+ List<Object> params = new ArrayList<>();
+ IRowMeta paramMeta = new RowMeta();
+ List<String> where = new ArrayList<>();
+
+ appendSelectorFilters(activeSelector, where, paramMeta, params);
+
+ if (!where.isEmpty()) {
+ sql.append(" WHERE ");
+ sql.append(String.join(" AND ", where));
+ }
+
+ sql.append(" ORDER BY ")
+ .append(databaseMeta.quoteField(COL_EXECUTION_START_DATE))
+ .append(" DESC");
+
+ if (limit > 0) {
+ sql.append(databaseMeta.getLimitClause(limit));
+ }
+
+ synchronized (database) {
+ ResultSet rs = database.openQuery(sql.toString(), paramMeta,
params.toArray());
+ try {
+ Object[] row = database.getRow(rs);
+ while (row != null) {
+ String id = row[0] != null ? row[0].toString() : null;
+ Date startDate = null;
+ if (row[1] instanceof Date date) {
+ startDate = date;
+ } else if (row[1] != null) {
+ // Timestamp / other
+ startDate = (Date) row[1];
+ }
+ if (id != null) {
+ ids.add(new DatedId(id, startDate != null ? startDate : new
Date(0L)));
+ if (includeChildren && !activeSelector.isSelectingParents()) {
+ CacheEntry entry = loadCacheEntry(id);
+ if (entry != null) {
+ addChildIds(entry, ids, activeSelector);
+ }
+ }
+ }
+ row = database.getRow(rs);
+ }
+ } finally {
+ database.closeQuery(rs);
+ }
+ }
+ } catch (Exception e) {
+ throw new HopException(
+ "Error finding execution ids from database table " +
getQuotedSchemaTable(), e);
+ }
+ }
+
+ private void appendSelectorFilters(
+ IExecutionSelector selector, List<String> where, IRowMeta paramMeta,
List<Object> params) {
+ if (selector == IExecutionSelector.ALL) {
+ return;
+ }
+
+ LastPeriod dateFilter = selector.startDateFilter();
+ if (dateFilter != null && dateFilter != LastPeriod.NONE) {
+ LocalDateTime start = dateFilter.calculateStartDate();
+ Date startDate =
Date.from(start.atZone(ZoneId.systemDefault()).toInstant());
+ where.add(databaseMeta.quoteField(COL_EXECUTION_START_DATE) + " >= ?");
+ paramMeta.addValueMeta(new ValueMetaDate(COL_EXECUTION_START_DATE));
+ params.add(startDate);
+ }
+
+ if (selector.isSelectingPipelines()) {
+ where.add(databaseMeta.quoteField(COL_EXECUTION_TYPE) + " = ?");
+ paramMeta.addValueMeta(new ValueMetaString(COL_EXECUTION_TYPE, 32, -1));
+ params.add("Pipeline");
+ }
+ if (selector.isSelectingWorkflows()) {
+ where.add(databaseMeta.quoteField(COL_EXECUTION_TYPE) + " = ?");
+ paramMeta.addValueMeta(new ValueMetaString(COL_EXECUTION_TYPE, 32, -1));
+ params.add("Workflow");
+ }
+ if (selector.isSelectingFailed()) {
+ where.add(databaseMeta.quoteField(COL_FAILED) + " = ?");
+ paramMeta.addValueMeta(new ValueMetaBoolean(COL_FAILED));
+ params.add(Boolean.TRUE);
+ }
+ if (selector.isSelectingFinished()) {
+ where.add("LOWER(" + databaseMeta.quoteField(COL_STATUS_DESCRIPTION) +
") LIKE ?");
+ paramMeta.addValueMeta(new ValueMetaString(COL_STATUS_DESCRIPTION, 128,
-1));
+ params.add("finished%");
+ }
+ if (selector.isSelectingRunning()) {
+ where.add(
+ "(LOWER("
+ + databaseMeta.quoteField(COL_STATUS_DESCRIPTION)
+ + ") LIKE ? OR LOWER("
+ + databaseMeta.quoteField(COL_STATUS_DESCRIPTION)
+ + ") LIKE ?)");
+ paramMeta.addValueMeta(new ValueMetaString(COL_STATUS_DESCRIPTION, 128,
-1));
+ params.add("running%");
+ paramMeta.addValueMeta(new ValueMetaString(COL_STATUS_DESCRIPTION + "2",
128, -1));
+ params.add("initializing%");
+ }
+ if (selector.isSelectingParents()) {
+ where.add(
+ "("
+ + databaseMeta.quoteField(COL_PARENT_ID)
+ + " IS NULL OR "
+ + databaseMeta.quoteField(COL_PARENT_ID)
+ + " = '')");
+ }
+
+ String filterText = selector.filterText();
+ if (StringUtils.isNotEmpty(filterText) && !selector.isSelectingByUuid()) {
+ String like = "%" + filterText.toLowerCase() + "%";
+ where.add(
+ "(LOWER("
+ + databaseMeta.quoteField(COL_NAME)
+ + ") LIKE ? OR LOWER("
+ + databaseMeta.quoteField(COL_ID)
+ + ") LIKE ?)");
+ paramMeta.addValueMeta(new ValueMetaString(COL_NAME, 1024, -1));
+ params.add(like);
+ paramMeta.addValueMeta(new ValueMetaString(COL_ID, 100, -1));
+ params.add(like);
+ }
+ }
+
+ protected IRowMeta createDataRowMeta() {
+ IRowMeta rowMeta = new RowMeta();
+ rowMeta.addValueMeta(new ValueMetaString(COL_ID, 100, -1));
+ rowMeta.addValueMeta(new ValueMetaString(COL_NAME, 1024, -1));
+ rowMeta.addValueMeta(new ValueMetaString(COL_EXECUTION_TYPE, 32, -1));
+ rowMeta.addValueMeta(new ValueMetaString(COL_PARENT_ID, 100, -1));
+ rowMeta.addValueMeta(new ValueMetaDate(COL_REGISTRATION_DATE));
+ rowMeta.addValueMeta(new ValueMetaDate(COL_EXECUTION_START_DATE));
+ rowMeta.addValueMeta(new ValueMetaDate(COL_EXECUTION_END_DATE));
+ rowMeta.addValueMeta(new ValueMetaBoolean(COL_FAILED));
+ rowMeta.addValueMeta(new ValueMetaString(COL_STATUS_DESCRIPTION, 128, -1));
+ // length 15 → BIGINT on most dialects (default Integer length maps to
tinyint on H2)
+ rowMeta.addValueMeta(new ValueMetaInteger(COL_DURATION_MS, 15, 0));
+ // CLOB for full CacheEntry JSON
+ rowMeta.addValueMeta(new ValueMetaString(COL_JSON,
DatabaseMeta.CLOB_LENGTH, -1));
+ return rowMeta;
+ }
+
+ private Object[] buildRowData(CacheEntry cacheEntry, String json) {
+ Execution execution = cacheEntry.getExecution();
+ ExecutionState state = cacheEntry.getExecutionState();
+
+ String name = cacheEntry.getName();
+ if (StringUtils.isEmpty(name) && execution != null) {
+ name = execution.getName();
+ }
+ String executionType =
+ execution != null && execution.getExecutionType() != null
+ ? execution.getExecutionType().name()
+ : null;
+ String parentId = execution != null ? execution.getParentId() : null;
+ Date registrationDate = execution != null ?
execution.getRegistrationDate() : null;
+ Date startDate = execution != null ? execution.getExecutionStartDate() :
null;
+ Date endDate = state != null ? state.getExecutionEndDate() : null;
+ Boolean failed = state != null ? state.isFailed() : null;
+ String status = state != null ? state.getStatusDescription() : null;
+ Long durationMs =
+ cacheEntry.getSummary() != null ?
cacheEntry.getSummary().getDurationMs() : null;
+
+ return new Object[] {
+ cacheEntry.getId(),
+ name,
+ executionType,
+ parentId,
+ registrationDate,
+ startDate,
+ endDate,
+ failed,
+ status,
+ durationMs,
+ json
+ };
+ }
+
+ protected String getQuotedSchemaTable() {
+ if (database != null) {
+ return databaseMeta.getQuotedSchemaTableCombination(
+ database, actualSchemaName, actualTableName);
+ }
+ return databaseMeta.getQuotedSchemaTableCombination(
+ variables, actualSchemaName, actualTableName);
+ }
+
+ /**
+ * Build dialect-specific CREATE TABLE + CREATE INDEX DDL for the configured
connection and table.
+ */
+ public String buildDdl(IVariables vars) throws HopException {
+ DatabaseMeta meta = databaseMeta;
+ if (meta == null) {
+ if (StringUtils.isEmpty(connectionName)) {
+ throw new HopException("Please select a database connection first");
+ }
+ String conn = vars != null ? vars.resolve(connectionName) :
connectionName;
+ if (metadataProvider == null && HopGui.getInstance() != null) {
+ metadataProvider = HopGui.getInstance().getMetadataProvider();
+ }
+ if (metadataProvider == null) {
+ throw new HopException("No metadata provider available to load
connection '" + conn + "'");
+ }
+ meta = metadataProvider.getSerializer(DatabaseMeta.class).load(conn);
+ if (meta == null) {
+ throw new HopException("Database connection '" + conn + "' could not
be found");
+ }
+ }
+
+ String schema =
+ vars != null ? vars.resolve(Const.NVL(schemaName, "")) :
Const.NVL(schemaName, "");
+ String table =
+ vars != null
+ ? vars.resolve(Const.NVL(tableName, DEFAULT_TABLE_NAME))
+ : Const.NVL(tableName, DEFAULT_TABLE_NAME);
+ if (StringUtils.isEmpty(table)) {
+ table = DEFAULT_TABLE_NAME;
+ }
+
+ Database db =
+ new Database(new
LoggingObject("CachingDatabaseExecutionInfoLocation"), vars, meta);
+ // No connect required for DDL generation
+ String schemaTable = meta.getQuotedSchemaTableCombination(vars, schema,
table);
+
+ IRowMeta fields = createDataRowMeta();
+ StringBuilder ddl = new StringBuilder();
+ ddl.append(db.getCreateTableStatement(schemaTable, fields, null, false,
COL_ID, true));
+ ddl.append(Const.CR);
+
+ addIndexDdl(ddl, db, schemaTable, "idx_hop_exec_start",
COL_EXECUTION_START_DATE);
+ addIndexDdl(ddl, db, schemaTable, "idx_hop_exec_name", COL_NAME);
+ addIndexDdl(ddl, db, schemaTable, "idx_hop_exec_type", COL_EXECUTION_TYPE);
+ addIndexDdl(ddl, db, schemaTable, "idx_hop_exec_failed", COL_FAILED);
+ addIndexDdl(ddl, db, schemaTable, "idx_hop_exec_parent", COL_PARENT_ID);
+ addIndexDdl(ddl, db, schemaTable, "idx_hop_exec_status",
COL_STATUS_DESCRIPTION);
+
+ return ddl.toString();
+ }
+
+ private void addIndexDdl(
+ StringBuilder ddl, Database db, String schemaTable, String indexName,
String... columns) {
+ String indexSql =
+ db.getCreateIndexStatement(schemaTable, indexName, columns, false,
false, false, true);
+ if (StringUtils.isNotEmpty(indexSql)) {
+ ddl.append(indexSql);
+ if (!indexSql.trim().endsWith(";")) {
+ ddl.append(";");
+ }
+ ddl.append(Const.CR);
+ }
+ }
+
+ /** Show dialect-specific CREATE TABLE / CREATE INDEX DDL (Neo4j-style, does
not execute). */
+ @GuiWidgetElement(
+ id = "showDdlButton",
+ order = "035",
+ parentId = ExecutionInfoLocation.GUI_PLUGIN_ELEMENT_PARENT_ID,
+ type = GuiElementType.BUTTON,
+ label = "i18n::CachingDatabaseExecutionInfoLocation.ShowDdl.Label",
+ toolTip = "i18n::CachingDatabaseExecutionInfoLocation.ShowDdl.Tooltip")
+ public void showDdlButton(Object object) {
+ HopGui hopGui = HopGui.getInstance();
+ CachingDatabaseExecutionInfoLocation location =
(CachingDatabaseExecutionInfoLocation) object;
+ try {
+ location.metadataProvider = hopGui.getMetadataProvider();
+ String ddl = location.buildDdl(hopGui.getVariables());
+ EnterTextDialog dialog =
+ new EnterTextDialog(
+ hopGui.getShell(),
+ BaseMessages.getString(PKG,
"CachingDatabaseExecutionInfoLocation.DdlDialog.Title"),
+ BaseMessages.getString(PKG,
"CachingDatabaseExecutionInfoLocation.DdlDialog.Message"),
+ ddl);
+ dialog.setReadOnly();
+ dialog.open();
+ } catch (Exception e) {
+ new ErrorDialog(
+ hopGui.getShell(),
+ "Error",
+ "Error generating DDL for the execution information table",
+ e);
+ }
+ }
+
+ @Override
+ public String getPluginId() {
+ return PLUGIN_ID;
+ }
+
+ @Override
+ public void setPluginId(String pluginId) {
+ // Don't set anything
+ }
+
+ @Override
+ public String getPluginName() {
+ return "Caching Database location";
+ }
+
+ @Override
+ public void setPluginName(String pluginName) {
+ // Nothing to set
+ }
+}
diff --git
a/plugins/misc/execution-database/src/main/resources/org/apache/hop/execution/database/messages/messages_en_US.properties
b/plugins/misc/execution-database/src/main/resources/org/apache/hop/execution/database/messages/messages_en_US.properties
new file mode 100644
index 0000000000..8df64eeee8
--- /dev/null
+++
b/plugins/misc/execution-database/src/main/resources/org/apache/hop/execution/database/messages/messages_en_US.properties
@@ -0,0 +1,27 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+#
+CachingDatabaseExecutionInfoLocation.Connection.Label=Relational database
connection
+CachingDatabaseExecutionInfoLocation.Connection.Tooltip=The relational
database connection to store execution information in
+CachingDatabaseExecutionInfoLocation.SchemaName.Label=Schema name (optional)
+CachingDatabaseExecutionInfoLocation.SchemaName.Tooltip=Optional database
schema name for the execution information table
+CachingDatabaseExecutionInfoLocation.TableName.Label=Table name
+CachingDatabaseExecutionInfoLocation.TableName.Tooltip=The name of the table
that stores execution information (filter columns + JSON)
+CachingDatabaseExecutionInfoLocation.ShowDdl.Label=Show table and indexes DDL
+CachingDatabaseExecutionInfoLocation.ShowDdl.Tooltip=Generate dialect-specific
CREATE TABLE and CREATE INDEX statements for the configured connection
+CachingDatabaseExecutionInfoLocation.DdlDialog.Title=Execution information
table DDL
+CachingDatabaseExecutionInfoLocation.DdlDialog.Message=Dialect-specific DDL to
create the table and indexes. Run this on your database before writing
execution information.
diff --git a/plugins/misc/execution-database/src/main/resources/version.xml
b/plugins/misc/execution-database/src/main/resources/version.xml
new file mode 100644
index 0000000000..311a09665b
--- /dev/null
+++ b/plugins/misc/execution-database/src/main/resources/version.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+
+<version>${project.version}</version>
diff --git
a/plugins/misc/execution-database/src/test/java/org/apache/hop/execution/database/CachingDatabaseExecutionInfoLocationTest.java
b/plugins/misc/execution-database/src/test/java/org/apache/hop/execution/database/CachingDatabaseExecutionInfoLocationTest.java
new file mode 100644
index 0000000000..022bff0a46
--- /dev/null
+++
b/plugins/misc/execution-database/src/test/java/org/apache/hop/execution/database/CachingDatabaseExecutionInfoLocationTest.java
@@ -0,0 +1,295 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.execution.database;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.database.Database;
+import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.database.DatabasePluginType;
+import org.apache.hop.core.logging.LoggingObject;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.databases.h2.H2DatabaseMeta;
+import org.apache.hop.execution.DefaultExecutionSelector;
+import org.apache.hop.execution.Execution;
+import org.apache.hop.execution.ExecutionState;
+import org.apache.hop.execution.ExecutionType;
+import org.apache.hop.execution.IExecutionSelector;
+import org.apache.hop.execution.LastPeriod;
+import org.apache.hop.execution.caching.CacheEntry;
+import org.apache.hop.execution.caching.DatedId;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class CachingDatabaseExecutionInfoLocationTest {
+
+ private CachingDatabaseExecutionInfoLocation location;
+ private DatabaseMeta databaseMeta;
+ private Variables variables;
+ private MemoryMetadataProvider metadataProvider;
+
+ @BeforeAll
+ static void initHop() throws Exception {
+ HopClientEnvironment.init();
+
DatabasePluginType.getInstance().registerClassPathPlugin(H2DatabaseMeta.class);
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ variables = new Variables();
+ metadataProvider = new MemoryMetadataProvider();
+
+ // DB_CLOSE_DELAY=-1 keeps the in-memory DB alive after the DDL connection
closes
+ databaseMeta =
+ new DatabaseMeta(
+ "h2-exec",
+ "H2",
+ "Native",
+ "",
+ "mem:exec_info_" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1",
+ "",
+ "",
+ "");
+ databaseMeta.setSupportsBooleanDataType(true);
+ metadataProvider.getSerializer(DatabaseMeta.class).save(databaseMeta);
+
+ location = new CachingDatabaseExecutionInfoLocation();
+ location.setConnectionName("h2-exec");
+
location.setTableName(CachingDatabaseExecutionInfoLocation.DEFAULT_TABLE_NAME);
+ location.setPersistenceDelay("60000");
+ location.setMaxCacheAge("86400000");
+ location.setDatabaseMeta(databaseMeta);
+
+ // Create table using generated DDL
+ String ddl = location.buildDdl(variables);
+ assertTrue(ddl.toUpperCase().contains("CREATE"));
+ assertTrue(
+ ddl.contains(CachingDatabaseExecutionInfoLocation.DEFAULT_TABLE_NAME)
+ || ddl.contains("hop_executions"));
+
+ Database db =
+ new Database(
+ new LoggingObject("CachingDatabaseExecutionInfoLocationTest"),
variables, databaseMeta);
+ db.connect();
+ try {
+ db.execStatements(ddl);
+ } finally {
+ db.disconnect();
+ }
+
+ location.initialize(variables, metadataProvider);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (location != null) {
+ location.close();
+ }
+ }
+
+ @Test
+ void persistLoadAndDeleteRoundTrip() throws Exception {
+ String id = UUID.randomUUID().toString();
+ CacheEntry entry = sampleEntry(id, "MyPipeline", ExecutionType.Pipeline,
false, "Finished");
+
+ location.persistCacheEntry(entry);
+
+ CacheEntry loaded = location.loadCacheEntry(id);
+ assertNotNull(loaded);
+ assertEquals(id, loaded.getId());
+ assertEquals("MyPipeline", loaded.getName());
+ assertNotNull(loaded.getExecution());
+ assertEquals(ExecutionType.Pipeline,
loaded.getExecution().getExecutionType());
+ assertNotNull(loaded.getExecutionState());
+ assertFalse(loaded.getExecutionState().isFailed());
+
+ location.deleteCacheEntry(entry);
+ assertNull(location.loadCacheEntry(id));
+ }
+
+ @Test
+ void upsertUpdatesFilterColumnsAndJson() throws Exception {
+ String id = UUID.randomUUID().toString();
+ CacheEntry entry = sampleEntry(id, "FlowA", ExecutionType.Workflow, false,
"Running");
+ location.persistCacheEntry(entry);
+
+ entry.getExecutionState().setFailed(true);
+ entry.getExecutionState().setStatusDescription("Finished");
+ entry.getExecutionState().setExecutionEndDate(new Date());
+ entry.setName("FlowA");
+ location.persistCacheEntry(entry);
+
+ CacheEntry loaded = location.loadCacheEntry(id);
+ assertNotNull(loaded);
+ assertTrue(loaded.getExecutionState().isFailed());
+
assertTrue(loaded.getExecutionState().getStatusDescription().startsWith("Finished"));
+
+ // Still a single row: retrieveIds returns one id
+ Set<DatedId> ids = new HashSet<>();
+ location.retrieveIds(false, ids, 100, IExecutionSelector.ALL);
+ assertEquals(1, ids.size());
+ }
+
+ @Test
+ void retrieveIdsFiltersByTypeAndFailed() throws Exception {
+ location.persistCacheEntry(
+ sampleEntry(UUID.randomUUID().toString(), "p1",
ExecutionType.Pipeline, false, "Finished"));
+ location.persistCacheEntry(
+ sampleEntry(UUID.randomUUID().toString(), "p2",
ExecutionType.Pipeline, true, "Finished"));
+ location.persistCacheEntry(
+ sampleEntry(UUID.randomUUID().toString(), "w1",
ExecutionType.Workflow, false, "Finished"));
+
+ Set<DatedId> pipelineIds = new HashSet<>();
+ location.retrieveIds(
+ false,
+ pipelineIds,
+ 100,
+ new DefaultExecutionSelector(
+ false, false, false, false, false, true, null,
LastPeriod.ONE_YEAR));
+ assertEquals(2, pipelineIds.size());
+
+ Set<DatedId> failedIds = new HashSet<>();
+ location.retrieveIds(
+ false,
+ failedIds,
+ 100,
+ new DefaultExecutionSelector(
+ false, true, false, false, false, false, null,
LastPeriod.ONE_YEAR));
+ assertEquals(1, failedIds.size());
+
+ Set<DatedId> workflowIds = new HashSet<>();
+ location.retrieveIds(
+ false,
+ workflowIds,
+ 100,
+ new DefaultExecutionSelector(
+ false, false, false, false, true, false, null,
LastPeriod.ONE_YEAR));
+ assertEquals(1, workflowIds.size());
+ }
+
+ @Test
+ void retrieveIdsFiltersByName() throws Exception {
+ location.persistCacheEntry(
+ sampleEntry(
+ UUID.randomUUID().toString(), "AlphaPipe", ExecutionType.Pipeline,
false, "Finished"));
+ location.persistCacheEntry(
+ sampleEntry(
+ UUID.randomUUID().toString(), "BetaPipe", ExecutionType.Pipeline,
false, "Finished"));
+
+ Set<DatedId> ids = new HashSet<>();
+ location.retrieveIds(
+ false,
+ ids,
+ 100,
+ new DefaultExecutionSelector(
+ false, false, false, false, false, false, "alpha",
LastPeriod.ONE_YEAR));
+ assertEquals(1, ids.size());
+ }
+
+ @Test
+ void registerExecutionPersistsParent() throws Exception {
+ String id = UUID.randomUUID().toString();
+ Execution execution = new Execution();
+ execution.setId(id);
+ execution.setName("Registered");
+ execution.setExecutionType(ExecutionType.Pipeline);
+ execution.setExecutionStartDate(new Date());
+ execution.setRegistrationDate(new Date());
+
+ location.registerExecution(execution);
+
+ CacheEntry loaded = location.loadCacheEntry(id);
+ assertNotNull(loaded);
+ assertEquals("Registered", loaded.getName());
+ }
+
+ @Test
+ void getExecutionIdsReturnsNewestFirst() throws Exception {
+ String oldId = UUID.randomUUID().toString();
+ String newId = UUID.randomUUID().toString();
+
+ CacheEntry oldEntry = sampleEntry(oldId, "Old", ExecutionType.Pipeline,
false, "Finished");
+ oldEntry.getExecution().setExecutionStartDate(new
Date(System.currentTimeMillis() - 60_000));
+ location.persistCacheEntry(oldEntry);
+
+ CacheEntry newEntry = sampleEntry(newId, "New", ExecutionType.Pipeline,
false, "Finished");
+ newEntry.getExecution().setExecutionStartDate(new Date());
+ location.persistCacheEntry(newEntry);
+
+ // Clear memory cache so list comes from DB
+ location.clearCaches();
+
+ List<String> ids = location.getExecutionIds(false, 10);
+ assertTrue(ids.size() >= 2);
+ assertEquals(newId, ids.get(0));
+ }
+
+ @Test
+ void buildDdlContainsIndexes() throws Exception {
+ String ddl = location.buildDdl(variables);
+ assertTrue(ddl.toLowerCase().contains("create"));
+ assertTrue(ddl.contains("idx_hop_exec_start") ||
ddl.toLowerCase().contains("index"));
+ assertTrue(
+ ddl.contains(CachingDatabaseExecutionInfoLocation.COL_JSON)
+ || ddl.toLowerCase().contains("json")
+ || ddl.toLowerCase().contains("clob")
+ || ddl.toLowerCase().contains("varchar")
+ || ddl.toLowerCase().contains("text")
+ || ddl.toLowerCase().contains("character"));
+ }
+
+ private static CacheEntry sampleEntry(
+ String id, String name, ExecutionType type, boolean failed, String
status) {
+ Execution execution = new Execution();
+ execution.setId(id);
+ execution.setName(name);
+ execution.setExecutionType(type);
+ execution.setExecutionStartDate(new Date());
+ execution.setRegistrationDate(new Date());
+
+ ExecutionState state = new ExecutionState();
+ state.setId(id);
+ state.setName(name);
+ state.setExecutionType(type);
+ state.setFailed(failed);
+ state.setStatusDescription(status);
+ state.setExecutionEndDate(new Date());
+ state.setUpdateTime(new Date());
+
+ CacheEntry entry = new CacheEntry();
+ entry.setId(id);
+ entry.setName(name);
+ entry.setExecution(execution);
+ entry.setExecutionState(state);
+ entry.calculateSummary();
+ return entry;
+ }
+}
diff --git a/plugins/misc/pom.xml b/plugins/misc/pom.xml
index 8af8f52b7a..83374e5c95 100644
--- a/plugins/misc/pom.xml
+++ b/plugins/misc/pom.xml
@@ -32,6 +32,7 @@
<module>async</module>
<module>debug</module>
<module>documentation</module>
+ <module>execution-database</module>
<module>git</module>
<module>import</module>
<module>mail</module>