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 889b515bc8 PipelineMeta cache can be stale in some cases, fixes #7841 
(#7869)
889b515bc8 is described below

commit 889b515bc8562cc9619809009e190ea2e47b1fb8
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Mon Aug 10 16:35:42 2026 +0200

    PipelineMeta cache can be stale in some cases, fixes #7841 (#7869)
---
 .../java/org/apache/hop/pipeline/PipelineMeta.java |  14 ++
 .../hop/pipeline/PipelineMetaHopEnabledTest.java   |  99 ++++++++
 .../HopGuiPipelineGraphHopEnablingTest.java        | 264 +++++++++++++++++++++
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  14 +-
 .../hop/ui/pipeline/dialog/PipelineHopDialog.java  |   5 +-
 5 files changed, 392 insertions(+), 4 deletions(-)

diff --git a/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java 
b/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
index f9f9f10c63..75717b752c 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
@@ -520,6 +520,20 @@ public class PipelineMeta extends AbstractMeta
     clearCaches();
   }
 
+  /**
+   * Enables or disables a hop. Use this instead of {@link 
PipelineHopMeta#setEnabled(boolean)}: the
+   * caches in this class take the enabled state of the hops into account, so 
they need to be
+   * cleared. A stale cache makes the engine look for row sets which were 
never allocated for a
+   * disabled hop, resulting in "Unable to find input rowset!" during 
initialization.
+   *
+   * @param hop The hop to enable or disable
+   * @param enabled true to enable the hop, false to disable it
+   */
+  public void setHopEnabled(PipelineHopMeta hop, boolean enabled) {
+    hop.setEnabled(enabled);
+    clearCaches();
+  }
+
   /**
    * Gets the list of used transforms, which are the transforms that are 
connected by hops.
    *
diff --git 
a/engine/src/test/java/org/apache/hop/pipeline/PipelineMetaHopEnabledTest.java 
b/engine/src/test/java/org/apache/hop/pipeline/PipelineMetaHopEnabledTest.java
new file mode 100644
index 0000000000..225d28e6b5
--- /dev/null
+++ 
b/engine/src/test/java/org/apache/hop/pipeline/PipelineMetaHopEnabledTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.pipeline;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.logging.LoggingObject;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.pipeline.engines.local.LocalPipelineEngine;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.dummy.DummyMeta;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Disabling a hop needs to invalidate the caches in {@link PipelineMeta}. If 
it doesn't, the engine
+ * allocates row sets with {@link 
PipelineMeta#findNextTransforms(TransformMeta)} (uncached, so the
+ * disabled hop is skipped) while the transforms look up their input with 
{@link
+ * PipelineMeta#findPreviousTransforms(TransformMeta, boolean)} (cached, so 
the disabled hop is
+ * still there). The transform then fails to initialize with "Unable to find 
input rowset!". See
+ * issue #7841.
+ */
+class PipelineMetaHopEnabledTest {
+
+  @BeforeAll
+  static void setUpBeforeClass() throws Exception {
+    HopEnvironment.init();
+  }
+
+  /** Two transforms A and C feeding into transform B. */
+  private PipelineMeta createPipelineMeta() {
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("hop enabled test");
+    TransformMeta a = new TransformMeta("A", new DummyMeta());
+    TransformMeta c = new TransformMeta("C", new DummyMeta());
+    TransformMeta b = new TransformMeta("B", new DummyMeta());
+    pipelineMeta.addTransform(a);
+    pipelineMeta.addTransform(c);
+    pipelineMeta.addTransform(b);
+    pipelineMeta.addPipelineHop(new PipelineHopMeta(a, b));
+    pipelineMeta.addPipelineHop(new PipelineHopMeta(c, b));
+    return pipelineMeta;
+  }
+
+  @Test
+  void disablingHopInvalidatesPreviousTransformsCache() {
+    PipelineMeta pipelineMeta = createPipelineMeta();
+    TransformMeta b = pipelineMeta.findTransform("B");
+
+    // Populate the cache while both hops are enabled.
+    //
+    assertEquals(2, pipelineMeta.findPreviousTransforms(b, true).size());
+
+    pipelineMeta.setHopEnabled(pipelineMeta.getPipelineHop(1), false);
+
+    assertEquals(1, pipelineMeta.findPreviousTransforms(b, true).size());
+    assertEquals("A", pipelineMeta.findPreviousTransforms(b, 
true).get(0).getName());
+  }
+
+  @Test
+  void disablingHopAfterARunDoesNotBreakTheNextRun() throws Exception {
+    PipelineMeta pipelineMeta = createPipelineMeta();
+
+    // A first execution populates the caches while all hops are still enabled.
+    //
+    LocalPipelineEngine firstRun =
+        new LocalPipelineEngine(pipelineMeta, new Variables(), new 
LoggingObject("test"));
+    firstRun.prepareExecution();
+    firstRun.startThreads();
+    firstRun.waitUntilFinished();
+
+    pipelineMeta.setHopEnabled(pipelineMeta.getPipelineHop(1), false);
+
+    LocalPipelineEngine secondRun =
+        new LocalPipelineEngine(pipelineMeta, new Variables(), new 
LoggingObject("test"));
+    secondRun.prepareExecution();
+    secondRun.startThreads();
+    secondRun.waitUntilFinished();
+
+    assertTrue(secondRun.getErrors() == 0, "the run after disabling a hop 
reported errors");
+  }
+}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopEnablingTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopEnablingTest.java
new file mode 100644
index 0000000000..769dcae0c1
--- /dev/null
+++ 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopEnablingTest.java
@@ -0,0 +1,264 @@
+/*
+ * 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.ui.hopgui.file.pipeline;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+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.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.pipeline.PipelineHopMeta;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.ITransform;
+import org.apache.hop.pipeline.transform.ITransformData;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.hopgui.HopGuiEnvironment;
+import org.apache.hop.ui.hopgui.file.GraphCanvasTestBase;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Enabling and disabling a hop straight from the canvas, with ctrl-click and 
with the middle mouse
+ * button. Next to the state of the hop itself, each test checks what the 
engine will see: {@link
+ * PipelineMeta} caches the previous transforms of a transform, and a hop that 
is disabled without
+ * invalidating that cache leaves the transform looking for a row set which 
was never allocated,
+ * failing the next run with "Unable to find input rowset!".
+ *
+ * <p>Covers <a href="https://github.com/apache/hop/issues/7841";>issue 
#7841</a>.
+ */
+@Tag("uitest")
+class HopGuiPipelineGraphHopEnablingTest extends GraphCanvasTestBase {
+
+  private static final String FIRST_INPUT = "First input";
+  private static final String SECOND_INPUT = "Second input";
+  private static final String TARGET_TRANSFORM = "Write to log";
+  private static final Point FIRST_INPUT_LOCATION = new Point(60, 60);
+  private static final Point SECOND_INPUT_LOCATION = new Point(60, 300);
+  private static final Point TARGET_LOCATION = new Point(460, 180);
+
+  @BeforeAll
+  static void registerGuiPlugins() throws HopException {
+    HopGuiEnvironment.init();
+  }
+
+  @Test
+  void ctrlClickingAHopDisablesItAndTellsTheEngineAboutIt() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) ->
+            assertTogglingHop(bot, graph, pipelineMeta, spots, 1, SWT.MOD1));
+  }
+
+  @Test
+  void middleClickingAHopDisablesItAndTellsTheEngineAboutIt() {
+    onCanvas(
+        (bot, graph, pipelineMeta, spots) ->
+            assertTogglingHop(bot, graph, pipelineMeta, spots, 2, SWT.NONE));
+  }
+
+  /**
+   * Disables the second hop into the target with the given gesture and 
enables it again, checking
+   * the hop and the previous transforms of the target after each click.
+   */
+  private void assertTogglingHop(
+      SWTBot bot,
+      HopGuiPipelineGraph graph,
+      PipelineMeta pipelineMeta,
+      Spots spots,
+      int button,
+      int stateMask) {
+    TransformMeta first = pipelineMeta.findTransform(FIRST_INPUT);
+    TransformMeta second = pipelineMeta.findTransform(SECOND_INPUT);
+    TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+    PipelineHopMeta hop = pipelineMeta.findPipelineHop(second, target);
+    assertNotNull(hop, "the scene should hold the hop that is toggled here");
+
+    // Whatever the user did before reaching for the hop - a run, a dialog - 
left the previous
+    // transforms of the target cached, with both hops still enabled.
+    assertPreviousTransforms(pipelineMeta, target, first, second);
+
+    String disablingPopup = clickHop(bot, spots, button, stateMask);
+    assertAll(
+        "ctrl/middle click should disable the hop",
+        () -> assertFalse(hop.isEnabled(), "the click should have disabled the 
hop"),
+        () -> assertPreviousTransforms(pipelineMeta, target, first),
+        () -> assertNoDialog(disablingPopup),
+        () -> assertNoFailures());
+
+    String enablingPopup = clickHop(bot, spots, button, stateMask);
+    assertAll(
+        "clicking the hop again should enable it",
+        () -> assertTrue(hop.isEnabled(), "the second click should have 
enabled the hop again"),
+        () -> assertPreviousTransforms(pipelineMeta, target, first, second),
+        () -> assertNoDialog(enablingPopup),
+        () -> assertNoFailures());
+  }
+
+  /** Clicks the hop and reports the title of any dialog that opened, which 
there should not be. */
+  private String clickHop(SWTBot bot, Spots spots, int button, int stateMask) {
+    Set<Shell> before = openShells();
+    fire(spots.canvas, SWT.MouseDown, spots.scale, spots.hop, button, 
stateMask);
+    fireAsync(
+        spots.canvas, SWT.MouseUp, spots.scale, spots.hop, button, stateMask | 
buttonMask(button));
+    Shell popup = awaitNewShell(bot, before);
+    String title = titleOf(popup);
+    closeShell(bot, popup);
+    return title;
+  }
+
+  private static int buttonMask(int button) {
+    return button == 2 ? SWT.BUTTON2 : SWT.BUTTON1;
+  }
+
+  // ------------------------------------------------------------------ 
assertions
+
+  /**
+   * The transforms feeding {@code target}, as the engine asks for them when 
it hands every
+   * transform its input row sets. A disabled hop may not show up here.
+   */
+  private void assertPreviousTransforms(
+      PipelineMeta pipelineMeta, TransformMeta target, TransformMeta... 
expected) {
+    List<String> names =
+        onUi(() -> pipelineMeta.findPreviousTransforms(target, true)).stream()
+            .map(TransformMeta::getName)
+            .sorted()
+            .toList();
+    List<String> wanted = 
Arrays.stream(expected).map(TransformMeta::getName).sorted().toList();
+    assertEquals(
+        wanted, names, "the transforms the engine will read " + 
target.getName() + " from");
+  }
+
+  private void assertNoDialog(String dialogTitle) {
+    assertNull(dialogTitle, "no dialog may open here, but one did");
+  }
+
+  private void assertNoFailures() {
+    assertTrue(swallowed.isEmpty(), "the canvas must not throw, but got " + 
swallowed);
+  }
+
+  // ------------------------------------------------------------------ scene
+
+  /** What a test needs to aim at: the canvas, its scale and the middle of the 
hop under test. */
+  private record Spots(Canvas canvas, double scale, Point hop) {}
+
+  @FunctionalInterface
+  private interface CanvasTest {
+    void run(SWTBot bot, HopGuiPipelineGraph graph, PipelineMeta pipelineMeta, 
Spots spots);
+  }
+
+  private void onCanvas(CanvasTest test) {
+    AtomicReference<HopGuiPipelineGraph> graphRef = new AtomicReference<>();
+    AtomicReference<PipelineMeta> metaRef = new AtomicReference<>();
+
+    withScene(
+        shell -> {
+          shell.setSize(1000, 700);
+          shell.setLayout(new FormLayout());
+          // Keep the single click on the canvas synchronous and deterministic.
+          PropsUi.getInstance().setUseDoubleClickOnCanvas(false);
+
+          PipelineMeta pipelineMeta = buildPipeline();
+          metaRef.set(pipelineMeta);
+          graphRef.set(
+              new HopGuiPipelineGraph(
+                  shell,
+                  hopGui(),
+                  new ExplorerPerspective(),
+                  pipelineMeta,
+                  new HopPipelineFileType<>()));
+        },
+        bot -> {
+          HopGuiPipelineGraph graph = graphRef.get();
+          PipelineMeta pipelineMeta = metaRef.get();
+          test.run(bot, graph, pipelineMeta, aim(bot, graph, pipelineMeta));
+        });
+  }
+
+  /**
+   * Locates the icons and returns the middle of the hop between the second 
input and the target.
+   */
+  private Spots aim(SWTBot bot, HopGuiPipelineGraph graph, PipelineMeta 
pipelineMeta) {
+    Canvas canvas = onUi(graph::getCanvas);
+    double scale = canvasToGraphScale(graph);
+    AreaLookup lookup = graph::getVisibleAreaOwner;
+
+    awaitIcon(
+        bot,
+        lookup,
+        AreaOwner.AreaType.TRANSFORM_ICON,
+        pipelineMeta.findTransform(FIRST_INPUT),
+        FIRST_INPUT_LOCATION);
+    Point second =
+        awaitIcon(
+            bot,
+            lookup,
+            AreaOwner.AreaType.TRANSFORM_ICON,
+            pipelineMeta.findTransform(SECOND_INPUT),
+            SECOND_INPUT_LOCATION);
+    Point target =
+        awaitIcon(
+            bot,
+            lookup,
+            AreaOwner.AreaType.TRANSFORM_ICON,
+            pipelineMeta.findTransform(TARGET_TRANSFORM),
+            TARGET_LOCATION);
+
+    // The hop is a line between the two icon centres, which is how the graph 
looks it up too.
+    return new Spots(canvas, scale, midpoint(second, target));
+  }
+
+  /** Two transforms feeding a third one, so that disabling one hop still 
leaves the target fed. */
+  private static PipelineMeta buildPipeline() {
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("hop-enabling");
+
+    TransformMeta first = transform(FIRST_INPUT, FIRST_INPUT_LOCATION);
+    TransformMeta second = transform(SECOND_INPUT, SECOND_INPUT_LOCATION);
+    TransformMeta target = transform(TARGET_TRANSFORM, TARGET_LOCATION);
+    pipelineMeta.addTransform(first);
+    pipelineMeta.addTransform(second);
+    pipelineMeta.addTransform(target);
+    pipelineMeta.addPipelineHop(new PipelineHopMeta(first, target));
+    pipelineMeta.addPipelineHop(new PipelineHopMeta(second, target));
+    return pipelineMeta;
+  }
+
+  private static TransformMeta transform(String name, Point location) {
+    TransformMeta transformMeta =
+        new TransformMeta("Dummy", name, new BaseTransformMeta<ITransform, 
ITransformData>());
+    transformMeta.setLocation(location.x, location.y);
+    return transformMeta;
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index b1889fce9c..03fe189453 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -992,7 +992,7 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
         // the hop.
         //
         else if (event.button == 2 || (event.button == 1 && control)) {
-          hop.setEnabled(!hop.isEnabled());
+          setHopEnabled(hop, !hop.isEnabled());
           updateErrorMetaForHop(hop);
           updateGui();
         } else {
@@ -5539,6 +5539,14 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
           // in hop-run
           //
           String pipelineRunConfigurationName = 
executionConfiguration.getRunConfiguration();
+
+          // The engine looks up the previous transforms of every transform 
through the caches in
+          // PipelineMeta. Editing in the graph can leave those caches out of 
sync with the hops
+          // (a disabled hop that is still cached leads to "Unable to find 
input rowset!"), so make
+          // sure we always start a run with clean caches.
+          //
+          pipelineMeta.clearCaches();
+
           pipeline =
               PipelineEngineFactory.createPipelineEngine(
                   variables,
@@ -5733,6 +5741,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
 
         // Create a new pipeline to execution
         //
+        pipelineMeta.clearCaches();
         pipeline = new LocalPipelineEngine(pipelineMeta, variables, 
hopGui.getLoggingObject());
         pipeline.setPreview(true);
         pipeline.setVariable(IPipelineEngine.PIPELINE_IN_PREVIEW_MODE, "Y");
@@ -6398,8 +6407,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   }
 
   private void setHopEnabled(PipelineHopMeta hop, boolean enabled) {
-    hop.setEnabled(enabled);
-    pipelineMeta.clearCaches();
+    pipelineMeta.setHopEnabled(hop, enabled);
   }
 
   private void modalMessageDialog(String title, String message, int swtFlags) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/pipeline/dialog/PipelineHopDialog.java 
b/ui/src/main/java/org/apache/hop/ui/pipeline/dialog/PipelineHopDialog.java
index ef346307fa..2cedc449fa 100644
--- a/ui/src/main/java/org/apache/hop/ui/pipeline/dialog/PipelineHopDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/pipeline/dialog/PipelineHopDialog.java
@@ -155,7 +155,6 @@ public class PipelineHopDialog extends Dialog {
         new SelectionAdapter() {
           @Override
           public void widgetSelected(SelectionEvent e) {
-            input.setEnabled(!input.isEnabled());
             input.setChanged();
           }
         });
@@ -234,8 +233,10 @@ public class PipelineHopDialog extends Dialog {
   private void ok() {
     TransformMeta fromBackup = input.getFromTransform();
     TransformMeta toBackup = input.getToTransform();
+    boolean enabledBackup = input.isEnabled();
     input.setFromTransform(pipelineMeta.findTransform(wFrom.getText()));
     input.setToTransform(pipelineMeta.findTransform(wTo.getText()));
+    input.setEnabled(wEnabled.getSelection());
 
     pipelineMeta.clearCaches();
 
@@ -259,6 +260,8 @@ public class PipelineHopDialog extends Dialog {
     } else if (pipelineMeta.hasLoop(input.getToTransform())) {
       input.setFromTransform(fromBackup);
       input.setToTransform(toBackup);
+      input.setEnabled(enabledBackup);
+      pipelineMeta.clearCaches();
       MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
       mb.setMessage(BaseMessages.getString(PKG, 
"PipelineHopDialog.LoopsNotAllowed.DialogMessage"));
       mb.setText(BaseMessages.getString(PKG, 
"PipelineHopDialog.LoopsNotAllowed.DialogTitle"));

Reply via email to