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 99e734fc72 Fix pipelines going to waiting state on critical fail,
fixes #3861 (#8123)
99e734fc72 is described below
commit 99e734fc729c28767b400ac7bbf2d30518d336b3
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Aug 27 15:30:01 2026 +0200
Fix pipelines going to waiting state on critical fail, fixes #3861 (#8123)
---
.../java/org/apache/hop/pipeline/Pipeline.java | 41 +++++++-
.../engines/local/LocalPipelineEngine.java | 2 +
.../pipeline/messages/messages_en_US.properties | 1 +
.../java/org/apache/hop/pipeline/PipelineTest.java | 49 ++++++++++
.../engines/local/LocalPipelineEngineTest.java | 62 +++++++++++++
.../hop/avro/transforms/avrooutput/AvroOutput.java | 32 +++----
.../avro/transforms/avrooutput/AvroOutputMeta.java | 8 +-
.../avrooutput/AvroOutputMetaOutputTypeTest.java | 103 +++++++++++++++++++++
.../transforms/cubeinput/CubeInputMeta.java | 4 +
.../cubeinput/messages/messages_en_US.properties | 1 +
.../transforms/cubeinput/CubeInputMetaTest.java | 20 ++++
.../transforms/jdbcmetadata/JdbcMetadataMeta.java | 10 +-
.../jdbcmetadata/JdbcMetadataMetaTest.java | 27 ++++++
13 files changed, 336 insertions(+), 24 deletions(-)
diff --git a/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
b/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
index 0306757303..cd8ba66612 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
@@ -583,6 +583,10 @@ public abstract class Pipeline
try {
prepareExecutionInternal();
prepared = true;
+ } catch (Throwable e) {
+ // Still on the bound namespace: bringing the pipeline down can touch
its files.
+ flagPreparationFailure(e);
+ throw e;
} finally {
HopVfsNamespaces.restoreThread(previous);
if (!prepared) {
@@ -602,6 +606,31 @@ public abstract class Pipeline
}
}
+ /**
+ * A pipeline whose preparation failed never runs, and never reaches a
terminal state by itself:
+ * it is left flagged as preparing or initializing. A Hop server keeps such
an object in its map
+ * forever, because the timer that purges stale objects only collects the
ones that are finished
+ * or stopped. Stop the pipeline and record the error so it is reported as a
failure and can be
+ * cleaned up. See issue #3861.
+ */
+ protected void flagPreparationFailure(Throwable e) {
+ errors.incrementAndGet();
+ // Nothing else logs this: the exception travels up to whoever asked for
the execution, which
+ // on a server is an HTTP reply that the pipeline's own log never sees.
Report it the way a
+ // pipeline reports any other error, so it shows up wherever the log does.
+ if (e != null) {
+ log.logError(
+ BaseMessages.getString(PKG, "Pipeline.Log.ErrorPreparingPipeline",
e.getMessage()), e);
+ }
+ if (isFinished() || isStopped()) {
+ // An inner failure handler already brought the pipeline to a terminal
state.
+ return;
+ }
+ // Stopping is the terminal state this pipeline can still reach through
the normal path: it
+ // releases whatever was already initialized and alerts the execution
stopped listeners.
+ stopAll();
+ }
+
private void prepareExecutionInternal() throws HopException {
setPreparing(true);
executionStartDate = new Date();
@@ -1846,14 +1875,20 @@ public abstract class Pipeline
/** Stops all transforms from running, and alerts any registered listeners.
*/
@Override
public void stopAll() {
- if (transforms == null || isAlreadyStopped.get()) {
+ if (isAlreadyStopped.get()) {
return;
}
- transforms.forEach(combi -> stopTransform(combi, false));
+ // A pipeline that never got as far as allocating its transforms can still
be stopped: it just
+ // has nothing to stop. Bailing out here used to leave it without a
terminal state.
+ if (transforms != null) {
+ transforms.forEach(combi -> stopTransform(combi, false));
+ }
- // if it is stopped it is not paused
+ // if it is stopped it is not paused, nor is it still preparing or
initializing
setPaused(false);
+ setPreparing(false);
+ setInitializing(false);
setStopped(true);
isAlreadyStopped.set(true);
diff --git
a/engine/src/main/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngine.java
b/engine/src/main/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngine.java
index 1ebefcc73b..fc24bcb602 100644
---
a/engine/src/main/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngine.java
+++
b/engine/src/main/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngine.java
@@ -341,6 +341,8 @@ public class LocalPipelineEngine extends Pipeline
implements IPipelineEngine<Pip
// Flag the pipeline as finished even if an exception was thrown
setFinished(true);
}
+ // Record the failure so the pipeline is reported as failed rather than
as a clean finish.
+ flagPreparationFailure(e);
throw e;
}
}
diff --git
a/engine/src/main/resources/org/apache/hop/pipeline/messages/messages_en_US.properties
b/engine/src/main/resources/org/apache/hop/pipeline/messages/messages_en_US.properties
index 650c1c291f..42fb37bcd0 100644
---
a/engine/src/main/resources/org/apache/hop/pipeline/messages/messages_en_US.properties
+++
b/engine/src/main/resources/org/apache/hop/pipeline/messages/messages_en_US.properties
@@ -52,6 +52,7 @@ Pipeline.Log.AllocatingRowsets=Allocating rowsets...
Pipeline.Log.AllocatingTransformsAndTransformData=Allocating Transforms &
TransformData...
Pipeline.Log.copiesInfo=\ prevcopies \= {0}, nextcopies\={1}
Pipeline.Log.ErrorInitializingTransform=Error initializing transform [{0}]
+Pipeline.Log.ErrorPreparingPipeline=The pipeline could not be prepared for
execution\: {0}
Pipeline.Log.ExecutionStartedForFilename=Execution started for filename [{0}]
Pipeline.Log.ExecutionStartedForPipeline=Execution started for pipeline [{0}]
Pipeline.Log.FailToInitializeAtLeastOneTransform=We failed to initialize at
least one transform. Execution can not begin\!
diff --git a/engine/src/test/java/org/apache/hop/pipeline/PipelineTest.java
b/engine/src/test/java/org/apache/hop/pipeline/PipelineTest.java
index 3b60c41f4e..4027a1fe51 100644
--- a/engine/src/test/java/org/apache/hop/pipeline/PipelineTest.java
+++ b/engine/src/test/java/org/apache/hop/pipeline/PipelineTest.java
@@ -18,7 +18,9 @@
package org.apache.hop.pipeline;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
@@ -339,4 +341,51 @@ class PipelineTest {
"Original value defined at run execution",
pipelineTest.getVariable(Const.INTERNAL_VARIABLE_ENTRY_CURRENT_FOLDER));
}
+
+ /**
+ * Issue #3861: a pipeline that never got as far as allocating its
transforms could not be stopped
+ * at all, so it kept whatever non-terminal state it was in. Nothing ever
cleaned it up.
+ */
+ @Test
+ void stopAllWorksBeforeTheTransformsAreAllocated() {
+ Pipeline pipeline = new LocalPipelineEngine(new PipelineMeta());
+ pipeline.setLogChannel(mock(ILogChannel.class));
+ pipeline.setPreparing(true);
+
+ pipeline.stopAll();
+
+ assertTrue(pipeline.isStopped());
+ assertEquals(Pipeline.STRING_STOPPED, pipeline.getStatusDescription());
+ }
+
+ /**
+ * A stopped pipeline is no longer preparing or initializing. The Hop GUI
reads those flags to
+ * decide whether a pipeline is still on the go, so they may not survive a
stop.
+ */
+ @Test
+ void stopAllClearsTheTransientExecutionFlags() {
+ Pipeline pipeline = new LocalPipelineEngine(new PipelineMeta());
+ pipeline.setLogChannel(mock(ILogChannel.class));
+ pipeline.setPreparing(true);
+ pipeline.setInitializing(true);
+
+ pipeline.stopAll();
+
+ assertFalse(pipeline.isPreparing());
+ assertFalse(pipeline.isInitializing());
+ }
+
+ /** Stopping stays a one-shot operation. */
+ @Test
+ void stopAllOnlyFiresTheStoppedListenersOnce() {
+ Pipeline pipeline = new LocalPipelineEngine(new PipelineMeta());
+ pipeline.setLogChannel(mock(ILogChannel.class));
+ int[] stopped = new int[1];
+ pipeline.addExecutionStoppedListener(p -> stopped[0]++);
+
+ pipeline.stopAll();
+ pipeline.stopAll();
+
+ assertEquals(1, stopped[0]);
+ }
}
diff --git
a/engine/src/test/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngineTest.java
b/engine/src/test/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngineTest.java
index 94e6e77e18..b7b6c4408e 100644
---
a/engine/src/test/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngineTest.java
+++
b/engine/src/test/java/org/apache/hop/pipeline/engines/local/LocalPipelineEngineTest.java
@@ -18,6 +18,7 @@
package org.apache.hop.pipeline.engines.local;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doThrow;
@@ -27,8 +28,11 @@ import static org.mockito.Mockito.verify;
import org.apache.hop.core.HopEnvironment;
import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.Pipeline;
import org.apache.hop.pipeline.PipelineMeta;
import org.apache.hop.pipeline.engine.EngineComponent.ComponentExecutionStatus;
import org.apache.hop.pipeline.transform.BaseTransformData;
@@ -71,6 +75,59 @@ class LocalPipelineEngineTest {
assertTrue(pipeline.isFinished(), "The pipeline should be flagged as
finished");
}
+ /**
+ * Issue #3861: a pipeline that fails after preparation used to be reported
as a clean finish,
+ * with nothing on the status page to say why it never produced a row.
+ */
+ @Test
+ void preparationFailureAfterInitIsReportedAsAnError() {
+ LocalPipelineEngine pipeline =
+ new LocalPipelineEngine(new PipelineMeta()) {
+ @Override
+ public void addTransformExecutionSamplers() throws HopException {
+ throw new HopException("Unable to attach the samplers");
+ }
+ };
+ pipeline.setLogChannel(mock(ILogChannel.class));
+
+ assertThrows(HopException.class, pipeline::prepareExecution);
+
+ assertTrue(pipeline.getErrors() > 0, "The failure should be counted as an
error");
+ assertTrue(
+ pipeline.getStatusDescription().contains("with errors"),
pipeline.getStatusDescription());
+ assertTrue(
+ logOf(pipeline).contains("Unable to attach the samplers"),
+ "The failure should be in the pipeline log: " + logOf(pipeline));
+ }
+
+ /**
+ * Issue #3861: when preparation fails part way through, the pipeline is
left flagged as preparing
+ * and so never reaches a terminal state. A Hop server keeps such an object
in its map forever,
+ * because the timer that purges stale objects only collects finished or
stopped ones.
+ */
+ @Test
+ void preparationFailureLeavesThePipelineInATerminalState() {
+ LocalPipelineEngine pipeline =
+ new LocalPipelineEngine(new PipelineMeta()) {
+ @Override
+ public void activateParameters(IVariables variables) {
+ throw new IllegalStateException("Preparation blew up half way
through");
+ }
+ };
+ pipeline.setLogChannel(mock(ILogChannel.class));
+
+ assertThrows(IllegalStateException.class, pipeline::prepareExecution);
+
+ assertFalse(pipeline.isPreparing(), "A failed preparation may not stay
flagged as preparing");
+ assertFalse(
+ pipeline.isInitializing(), "A failed preparation may not stay flagged
as initializing");
+ assertTrue(pipeline.isStopped(), "A failed preparation should leave a
terminal state");
+ assertEquals(Pipeline.STRING_STOPPED, pipeline.getStatusDescription());
+ assertTrue(
+ logOf(pipeline).contains("Preparation blew up half way through"),
+ "The failure should be in the pipeline log: " + logOf(pipeline));
+ }
+
@Test
void disposeInitializedTransformsDisposesEveryTransform() throws Exception {
LocalPipelineEngine pipeline = new LocalPipelineEngine(new PipelineMeta());
@@ -118,4 +175,9 @@ class LocalPipelineEngineTest {
combi.data = data;
return combi;
}
+
+ /** The pipeline log as a Hop server would serve it on the status page. */
+ private static String logOf(LocalPipelineEngine pipeline) {
+ return HopLogStore.getAppender().getBuffer(pipeline.getLogChannelId(),
false).toString();
+ }
}
diff --git
a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrooutput/AvroOutput.java
b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrooutput/AvroOutput.java
index 776aff7572..2a98dc0b82 100644
---
a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrooutput/AvroOutput.java
+++
b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrooutput/AvroOutput.java
@@ -342,11 +342,11 @@ public class AvroOutput extends
BaseTransform<AvroOutputMeta, AvroOutputData> {
GenericRecord row = getRecord(r, null, data.avroSchema);
try {
- if (meta.getOutputType()
-
.equals(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_BINARY_FILE])) {
+ if
(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_BINARY_FILE].equals(
+ meta.getOutputType())) {
data.dataFileWriter.append(row);
- } else if (meta.getOutputType()
-
.equals(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_FIELD])) {
+ } else if
(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_FIELD].equals(
+ meta.getOutputType())) {
data.datumWriter.write(row, data.binaryEncoder);
data.binaryEncoder.flush();
data.byteArrayOutputStream.flush();
@@ -354,8 +354,8 @@ public class AvroOutput extends
BaseTransform<AvroOutputMeta, AvroOutputData> {
r, data.outputRowMeta.size() - 1,
data.byteArrayOutputStream.toByteArray());
data.byteArrayOutputStream.close();
data.byteArrayOutputStream.reset();
- } else if (meta.getOutputType()
-
.equals(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_JSON_FIELD])) {
+ } else if
(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_JSON_FIELD].equals(
+ meta.getOutputType())) {
data.datumWriter.write(row, data.jsonEncoder);
data.jsonEncoder.flush();
data.byteArrayOutputStream.flush();
@@ -399,13 +399,13 @@ public class AvroOutput extends
BaseTransform<AvroOutputMeta, AvroOutputData> {
}
data.datumWriter = new GenericDatumWriter<>(data.avroSchema);
- if (meta.getOutputType()
-
.equals(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_FIELD])) {
+ if (AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_FIELD].equals(
+ meta.getOutputType())) {
data.encoderFactory = new EncoderFactory();
data.byteArrayOutputStream = new ByteArrayOutputStream();
data.binaryEncoder =
data.encoderFactory.binaryEncoder(data.byteArrayOutputStream, null);
- } else if (meta.getOutputType()
-
.equals(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_BINARY_FILE])) {
+ } else if
(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_BINARY_FILE].equals(
+ meta.getOutputType())) {
data.dataFileWriter = new DataFileWriter<>(data.datumWriter);
if (!Utils.isEmpty(meta.getCompressionType())
&& !meta.getCompressionType().equalsIgnoreCase("none")) {
@@ -414,8 +414,8 @@ public class AvroOutput extends
BaseTransform<AvroOutputMeta, AvroOutputData> {
openNewFile(meta.getFileName());
data.dataFileWriter.create(data.avroSchema, data.writer);
- } else if (meta.getOutputType()
-
.equals(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_JSON_FIELD])) {
+ } else if
(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_JSON_FIELD].equals(
+ meta.getOutputType())) {
data.encoderFactory = new EncoderFactory();
data.byteArrayOutputStream = new ByteArrayOutputStream();
data.jsonEncoder =
@@ -429,8 +429,8 @@ public class AvroOutput extends
BaseTransform<AvroOutputMeta, AvroOutputData> {
}
private void closeOutput() throws HopException {
- if (meta.getOutputType()
-
.equals(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_FIELD])) {
+ if (AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_FIELD].equals(
+ meta.getOutputType())) {
try {
data.binaryEncoder = null;
data.jsonEncoder = null;
@@ -441,8 +441,8 @@ public class AvroOutput extends
BaseTransform<AvroOutputMeta, AvroOutputData> {
} catch (Exception ex) {
throw new HopException("Error cleaning up transform", ex);
}
- } else if (meta.getOutputType()
-
.equals(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_BINARY_FILE])) {
+ } else if
(AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_BINARY_FILE].equals(
+ meta.getOutputType())) {
closeFile();
}
data.datumWriter = null;
diff --git
a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrooutput/AvroOutputMeta.java
b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrooutput/AvroOutputMeta.java
index ac4e97968a..e991b9d1a5 100644
---
a/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrooutput/AvroOutputMeta.java
+++
b/plugins/tech/avro/src/main/java/org/apache/hop/avro/transforms/avrooutput/AvroOutputMeta.java
@@ -163,7 +163,7 @@ public class AvroOutputMeta extends
BaseTransformMeta<AvroOutput, AvroOutputData
@HopMetadataProperty(
key = "output_type",
injectionKeyDescription = "AvroOutput.Injection.OUTPUT_TYPE")
- private String outputType;
+ private String outputType = OUTPUT_TYPES[OUTPUT_TYPE_BINARY_FILE];
@HopMetadataProperty(
key = "output_field_name",
@@ -330,7 +330,7 @@ public class AvroOutputMeta extends
BaseTransformMeta<AvroOutput, AvroOutputData
if (outputTypeId >= 0 && outputTypeId < OUTPUT_TYPES.length) {
this.outputType = OUTPUT_TYPES[outputTypeId];
} else {
- this.outputType = null;
+ this.outputType = OUTPUT_TYPES[OUTPUT_TYPE_BINARY_FILE];
}
}
@@ -561,11 +561,11 @@ public class AvroOutputMeta extends
BaseTransformMeta<AvroOutput, AvroOutputData
throws HopTransformException {
// change the case insensitive flag too
- if (outputType.equalsIgnoreCase(OUTPUT_TYPES[OUTPUT_TYPE_FIELD])) {
+ if (OUTPUT_TYPES[OUTPUT_TYPE_FIELD].equalsIgnoreCase(outputType)) {
IValueMeta v = new ValueMetaBinary(variables.resolve(outputFieldName));
v.setOrigin(origin);
row.addValueMeta(v);
- } else if
(outputType.equalsIgnoreCase(OUTPUT_TYPES[OUTPUT_TYPE_JSON_FIELD])) {
+ } else if
(OUTPUT_TYPES[OUTPUT_TYPE_JSON_FIELD].equalsIgnoreCase(outputType)) {
IValueMeta valueMetaInterface = new
ValueMetaString(variables.resolve(outputFieldName));
valueMetaInterface.setOrigin(origin);
row.addValueMeta(valueMetaInterface);
diff --git
a/plugins/tech/avro/src/test/java/org/apache/hop/avro/transforms/avrooutput/AvroOutputMetaOutputTypeTest.java
b/plugins/tech/avro/src/test/java/org/apache/hop/avro/transforms/avrooutput/AvroOutputMetaOutputTypeTest.java
new file mode 100644
index 0000000000..a284f923ca
--- /dev/null
+++
b/plugins/tech/avro/src/test/java/org/apache/hop/avro/transforms/avrooutput/AvroOutputMetaOutputTypeTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.avro.transforms.avrooutput;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Node;
+
+/**
+ * Issue #3861: an Avro File Output whose output type was never set took the
whole pipeline down
+ * with a NullPointerException while the pipeline was being prepared, which on
a Hop server left the
+ * pipeline stuck in the server's object list.
+ */
+class AvroOutputMetaOutputTypeTest {
+
+ /**
+ * setDefault() is only called by the GUI when a transform is dropped on the
canvas, so a file
+ * that carries no output_type tag has to fall back to the default on its
own.
+ */
+ @Test
+ void outputTypeDefaultsWithoutSetDefault() {
+ assertEquals(
+ AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_BINARY_FILE],
+ new AvroOutputMeta().getOutputType());
+ }
+
+ @Test
+ void outputTypeSurvivesLoadingATransformNodeWithoutTheTag() throws Exception
{
+ AvroOutputMeta meta = new AvroOutputMeta();
+ Node node =
+ XmlHandler.getSubNode(
+ XmlHandler.loadXmlString(
+
"<transform><name>avro</name><type>AvroOutput</type></transform>"),
+ "transform");
+ meta.loadXml(node, new MemoryMetadataProvider());
+
+ assertEquals(
+ AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_BINARY_FILE],
meta.getOutputType());
+ assertDoesNotThrow(() -> getFields(meta));
+ }
+
+ /** An unselected combo hands the dialog -1; that must not null the output
type out. */
+ @Test
+ void unknownOutputTypeIdFallsBackToTheDefault() {
+ AvroOutputMeta meta = new AvroOutputMeta();
+ meta.setOutputTypeById(-1);
+
+ assertEquals(
+ AvroOutputMeta.OUTPUT_TYPES[AvroOutputMeta.OUTPUT_TYPE_BINARY_FILE],
meta.getOutputType());
+ assertEquals(AvroOutputMeta.OUTPUT_TYPE_BINARY_FILE,
meta.getOutputTypeId());
+ assertDoesNotThrow(() -> getFields(meta));
+ }
+
+ /** Even an explicitly nulled output type may not throw a raw
NullPointerException. */
+ @Test
+ void nullOutputTypeDoesNotThrow() {
+ AvroOutputMeta meta = new AvroOutputMeta();
+ meta.setOutputType(null);
+
+ assertDoesNotThrow(() -> getFields(meta));
+ }
+
+ /** The binary-field output type still contributes its field. */
+ @Test
+ void binaryFieldOutputTypeStillAddsTheField() throws Exception {
+ AvroOutputMeta meta = new AvroOutputMeta();
+ meta.setOutputTypeById(AvroOutputMeta.OUTPUT_TYPE_FIELD);
+ meta.setOutputFieldName("avro_record");
+
+ IRowMeta row = new RowMeta();
+ meta.getFields(row, "avro", null, null, new Variables(), new
MemoryMetadataProvider());
+
+ assertEquals(1, row.size());
+ assertEquals("avro_record", row.getValueMeta(0).getName());
+ }
+
+ private static void getFields(AvroOutputMeta meta) throws Exception {
+ meta.getFields(
+ new RowMeta(), "avro", null, null, new Variables(), new
MemoryMetadataProvider());
+ }
+}
diff --git
a/plugins/transforms/cubeinput/src/main/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInputMeta.java
b/plugins/transforms/cubeinput/src/main/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInputMeta.java
index b2f8d964f0..ea53a3c3b5 100644
---
a/plugins/transforms/cubeinput/src/main/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInputMeta.java
+++
b/plugins/transforms/cubeinput/src/main/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInputMeta.java
@@ -86,6 +86,10 @@ public class CubeInputMeta extends
BaseTransformMeta<CubeInput, CubeInputData> {
throws HopTransformException {
GZIPInputStream fis = null;
DataInputStream dis = null;
+ if (file == null || file.getName() == null) {
+ throw new HopTransformException(
+ BaseMessages.getString(PKG,
"CubeInputMeta.Exception.NoFilenameSpecified"));
+ }
try {
InputStream is =
HopVfs.getInputStream(
diff --git
a/plugins/transforms/cubeinput/src/main/resources/org/apache/hop/pipeline/transforms/cubeinput/messages/messages_en_US.properties
b/plugins/transforms/cubeinput/src/main/resources/org/apache/hop/pipeline/transforms/cubeinput/messages/messages_en_US.properties
index 7791db505c..acb694ea61 100644
---
a/plugins/transforms/cubeinput/src/main/resources/org/apache/hop/pipeline/transforms/cubeinput/messages/messages_en_US.properties
+++
b/plugins/transforms/cubeinput/src/main/resources/org/apache/hop/pipeline/transforms/cubeinput/messages/messages_en_US.properties
@@ -32,6 +32,7 @@ CubeInputDialog.Shell.Title=De-serialize from file
CubeInputDialog.TransformName.Label=Transform name
CubeInputMeta.CheckResult.FileSpecificationsNotChecked=File specifications are
not checked.
CubeInputMeta.Exception.ErrorOpeningOrReadingCubeFile=Error opening/reading
cube file
+CubeInputMeta.Exception.NoFilenameSpecified=No cube file name has been
specified for this transform
CubeInputMeta.Exception.UnableToCloseCubeFile=Unable to close cube file
CubeInputMeta.Exception.UnableToLoadTransformMeta=Unable to load transform
info from XML
CubeInputMeta.Exception.UnableToReadMetaData=Unable to read metadata from cube
file
diff --git
a/plugins/transforms/cubeinput/src/test/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInputMetaTest.java
b/plugins/transforms/cubeinput/src/test/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInputMetaTest.java
index 763749382f..361da84d8e 100644
---
a/plugins/transforms/cubeinput/src/test/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInputMetaTest.java
+++
b/plugins/transforms/cubeinput/src/test/java/org/apache/hop/pipeline/transforms/cubeinput/CubeInputMetaTest.java
@@ -18,7 +18,12 @@
package org.apache.hop.pipeline.transforms.cubeinput;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.variables.Variables;
import org.apache.hop.pipeline.transform.TransformSerializationTestUtil;
import org.junit.jupiter.api.Test;
@@ -32,4 +37,19 @@ class CubeInputMetaTest {
assertNotNull(meta.getFile());
assertNotNull(meta.getFile().getName());
}
+
+ /**
+ * Issue #3861: with no cube file configured, getFields() dereferenced the
file name and threw a
+ * raw NullPointerException out of prepareExecution instead of reporting the
misconfiguration.
+ */
+ @Test
+ void getFieldsWithoutFilenameReportsTheMisconfiguration() {
+ CubeInputMeta meta = new CubeInputMeta();
+
+ HopTransformException e =
+ assertThrows(
+ HopTransformException.class,
+ () -> meta.getFields(new RowMeta(), "cube input", null, null, new
Variables(), null));
+ assertTrue(e.getMessage().contains("cube file name"), e.getMessage());
+ }
}
diff --git
a/plugins/transforms/jdbc-metadata/src/main/java/org/apache/hop/pipeline/transforms/jdbcmetadata/JdbcMetadataMeta.java
b/plugins/transforms/jdbc-metadata/src/main/java/org/apache/hop/pipeline/transforms/jdbcmetadata/JdbcMetadataMeta.java
index 7254b3d817..e20c65088c 100644
---
a/plugins/transforms/jdbc-metadata/src/main/java/org/apache/hop/pipeline/transforms/jdbcmetadata/JdbcMetadataMeta.java
+++
b/plugins/transforms/jdbc-metadata/src/main/java/org/apache/hop/pipeline/transforms/jdbcmetadata/JdbcMetadataMeta.java
@@ -553,7 +553,7 @@ public class JdbcMetadataMeta extends
BaseTransformMeta<JdbcMetadata, JdbcMetada
/** Stores the selection of fields that are added to the stream */
@HopMetadataProperty(groupKey = "outputFields", key = "outputField")
- private List<OutputField> outputFields;
+ private List<OutputField> outputFields = new ArrayList<>();
/**
* @return the selection of fields added to the stream
@@ -597,6 +597,14 @@ public class JdbcMetadataMeta extends
BaseTransformMeta<JdbcMetadata, JdbcMetada
int n = outputFields.size();
Object[] methodDescriptor = getMethodDescriptor();
+ if (methodDescriptor == null) {
+ throw new HopTransformException(
+ "Unknown or missing JDBC metadata method '"
+ + getMethodName()
+ + "' in transform '"
+ + origin
+ + "'");
+ }
IValueMeta[] fields = (IValueMeta[]) methodDescriptor[2];
int m = fields.length;
IValueMeta field;
diff --git
a/plugins/transforms/jdbc-metadata/src/test/java/org/apache/hop/pipeline/transforms/jdbcmetadata/JdbcMetadataMetaTest.java
b/plugins/transforms/jdbc-metadata/src/test/java/org/apache/hop/pipeline/transforms/jdbcmetadata/JdbcMetadataMetaTest.java
index 3b855a0c24..8c28e58991 100644
---
a/plugins/transforms/jdbc-metadata/src/test/java/org/apache/hop/pipeline/transforms/jdbcmetadata/JdbcMetadataMetaTest.java
+++
b/plugins/transforms/jdbc-metadata/src/test/java/org/apache/hop/pipeline/transforms/jdbcmetadata/JdbcMetadataMetaTest.java
@@ -19,12 +19,16 @@ package org.apache.hop.pipeline.transforms.jdbcmetadata;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.variables.Variables;
import org.apache.hop.core.xml.XmlHandler;
import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
@@ -123,6 +127,29 @@ class JdbcMetadataMetaTest {
assertTrue(copy.getArguments().isEmpty());
}
+ /**
+ * Issue #3861: the method name is only filled in by setDefault(), which the
GUI calls but loading
+ * a pipeline does not. A file without it left the method descriptor null
and getFields() threw a
+ * raw NullPointerException while the pipeline was being prepared.
+ */
+ @Test
+ void getFieldsWithoutMethodNameReportsTheMisconfiguration() {
+ JdbcMetadataMeta meta = new JdbcMetadataMeta();
+
+ HopTransformException e =
+ assertThrows(
+ HopTransformException.class,
+ () ->
+ meta.getFields(new RowMeta(), "jdbc metadata", null, null, new
Variables(), null));
+ assertTrue(e.getMessage().contains("jdbc metadata"), e.getMessage());
+ }
+
+ /** The output field list is allocated up front, so a file without one is
still usable. */
+ @Test
+ void outputFieldsAreNeverNull() {
+ assertNotNull(new JdbcMetadataMeta().getOutputFields());
+ }
+
private static JdbcMetadataMeta serializeAndDeserialize(JdbcMetadataMeta
source)
throws Exception {
String xml =