This is an automated email from the ASF dual-hosted git repository.

mattcasters 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 a411bcad48 Issue #8952 : Fix the mail, lineage, OpenSearch, and 
samples integration failures (#8953)
a411bcad48 is described below

commit a411bcad48f522592fce4d82e9f730f211142817
Author: Matt Casters <[email protected]>
AuthorDate: Sat Oct 17 00:55:46 2026 +0200

    Issue #8952 : Fix the mail, lineage, OpenSearch, and samples integration 
failures (#8953)
    
    * Issue #8952 : Fix the mail, lineage, OpenSearch, and samples integration 
failures
    
    James WebAdmin was still starting when the mail tests created users, so the
    fixed 30s delay curled a closed port and the later mail tests had no 
accounts.
    The tests now wait until port 8000 accepts connections.
    
    Registering a child execution linked both the owner and the launcher with
    EXECUTES. shortestPath then skipped the action in the error-lineage path.
    Link the launcher when its node exists, and move an earlier owner shortcut
    onto the launcher when that node is stored later.
    
    An OpenSearch child location loaded a parent without the document _id and
    posted a second document, then could overwrite a finished state. Keep the
    document id, re-read before that link write, and refresh so the new state is
    visible.
    
    The samples suite was still moving when the 3600s watchdog killed it during
    the huge-file sample. That project now has a 5400s cap. The other projects
    keep 3600s.
    
    * Issue #8952 : Keep a finished OpenSearch state when an attach-only 
location writes
    
    A second location that loads a workflow only to attach actions kept that
    snapshot and wrote it back on close, replacing Finished with a stale copy.
    Re-read before that write and keep the newer state, including children from
    either copy. Drop refresh=wait_for; the re-read is a realtime get by 
document id.
    
    Co-authored-by: Matt Casters <[email protected]>
    
    ---------
    
    Co-authored-by: Cursor Agent <[email protected]>
---
 .../integration-tests-samples.yaml                 |  27 +++
 .../caching/BaseCachingExecutionInfoLocation.java  | 157 +++++++++++++++-
 .../apache/hop/execution/caching/CacheEntry.java   |   6 +
 .../BaseCachingExecutionInfoLocationTest.java      | 205 ++++++++++++++++++++-
 integration-tests/mail/main-0001-james-rest.hwf    |  37 ++--
 integration-tests/mail/main-0009-mail-with-zip.hwf |  16 +-
 .../neo4j/execution/NeoExecutionInfoLocation.java  |  75 +++++++-
 .../NeoExecutionInfoLocationDeleteTest.java        |  20 ++
 .../OpenSearchExecutionInfoLocation.java           | 105 +++++++++--
 ...OpenSearchExecutionInfoLocationCleanupTest.java |  45 +++++
 10 files changed, 646 insertions(+), 47 deletions(-)

diff --git a/docker/integration-tests/integration-tests-samples.yaml 
b/docker/integration-tests/integration-tests-samples.yaml
new file mode 100644
index 0000000000..b6c9be40f7
--- /dev/null
+++ b/docker/integration-tests/integration-tests-samples.yaml
@@ -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.
+
+# The samples suite runs every assembled sample in one JVM. A literal timeout 
is required:
+# run-tests-docker.sh exports HOP_IT_TIMEOUT before Compose interpolates the 
base file, so
+# ${HOP_IT_TIMEOUT:-3600} there cannot raise the cap for this project alone.
+services:
+  integration_test_samples:
+    extends:
+      file: integration-tests-base.yaml
+      service: integration_test
+    environment:
+      HOP_IT_TIMEOUT: "5400"
diff --git 
a/engine/src/main/java/org/apache/hop/execution/caching/BaseCachingExecutionInfoLocation.java
 
b/engine/src/main/java/org/apache/hop/execution/caching/BaseCachingExecutionInfoLocation.java
index 6db70834f4..06b8c94f8c 100644
--- 
a/engine/src/main/java/org/apache/hop/execution/caching/BaseCachingExecutionInfoLocation.java
+++ 
b/engine/src/main/java/org/apache/hop/execution/caching/BaseCachingExecutionInfoLocation.java
@@ -239,7 +239,7 @@ public abstract class BaseCachingExecutionInfoLocation 
implements IExecutionInfo
       for (CacheEntry cacheEntry : cache.values()) {
         if (cacheEntry.needsWriting(delay)) {
           try {
-            persistCacheEntry(cacheEntry);
+            saveCacheEntry(cacheEntry);
           } catch (Exception e) {
             LogChannel.GENERAL.logError(
                 "Error persisting cache entry for " + cacheEntry.getId(), e);
@@ -287,7 +287,7 @@ public abstract class BaseCachingExecutionInfoLocation 
implements IExecutionInfo
       CacheEntry cacheEntry = entry.getValue();
       if (cacheEntry != null && cacheEntry.isDirty()) {
         try {
-          persistCacheEntry(cacheEntry);
+          saveCacheEntry(cacheEntry);
         } catch (Exception e) {
           // A failed write must stay in memory. Dropping it here loses the 
only copy.
           LogChannel.GENERAL.logError(
@@ -312,7 +312,7 @@ public abstract class BaseCachingExecutionInfoLocation 
implements IExecutionInfo
       CacheEntry cacheEntry = mapEntry.getValue();
       if (cacheEntry != null && cacheEntry.isDirty()) {
         try {
-          persistCacheEntry(cacheEntry);
+          saveCacheEntry(cacheEntry);
         } catch (Exception e) {
           // Leave this entry. A later close() can retry it. Clearing it drops 
unsaved state.
           if (failure == null) {
@@ -348,7 +348,7 @@ public abstract class BaseCachingExecutionInfoLocation 
implements IExecutionInfo
       // CacheEntry from disk before calling registerData / register child 
executions.
       CacheEntry entry = cache.get(execution.getId());
       if (entry != null) {
-        persistCacheEntry(entry);
+        saveCacheEntry(entry);
       }
       rememberLaunchedChild(execution);
     } else {
@@ -392,7 +392,15 @@ public abstract class BaseCachingExecutionInfoLocation 
implements IExecutionInfo
         return;
       }
       parent.setDirty(true);
-      persistCacheEntry(parent);
+      // This location loaded the parent only to attach the link. The parent 
may have finished and
+      // written a newer state after that load. Writing the stale copy would 
drop logging text and
+      // the end date. Re-read and keep the newer state, then put the link 
back on it.
+      if (loadedForLink) {
+        parent = keepNewerParentState(parent, child);
+        // The link re-read just ran. This write is the merged document, then 
the entry is dropped.
+        parent.setLoadedCopy(false);
+      }
+      saveCacheEntry(parent);
       parent.setDirty(false);
     } catch (Exception e) {
       LogChannel.GENERAL.logError(
@@ -409,6 +417,139 @@ public abstract class BaseCachingExecutionInfoLocation 
implements IExecutionInfo
     }
   }
 
+  /**
+   * Write one cache entry. A document this location only loaded is merged 
with a fresh read first,
+   * so attaching a child cannot replace a finished execution state on close.
+   */
+  private void saveCacheEntry(CacheEntry cacheEntry) throws HopException {
+    if (cacheEntry == null) {
+      return;
+    }
+    mergeLoadedCopyBeforePersist(cacheEntry);
+    persistCacheEntry(cacheEntry);
+  }
+
+  /**
+   * A location that loaded a document to attach children keeps that snapshot 
until it closes. The
+   * execution that owns the document may have finished after the load. 
Re-read and keep that state,
+   * and keep the children recorded by either copy.
+   */
+  private void mergeLoadedCopyBeforePersist(CacheEntry loaded) throws 
HopException {
+    if (loaded == null || !loaded.isLoadedCopy()) {
+      return;
+    }
+    CacheEntry fresh = loadFreshCacheEntry(loaded);
+    if (fresh == null) {
+      return;
+    }
+    if (StringUtils.isEmpty(loaded.getInternalId())
+        && StringUtils.isNotEmpty(fresh.getInternalId())) {
+      loaded.setInternalId(fresh.getInternalId());
+    }
+    loaded.keepStoredProjectId(fresh);
+    copyMissingChildren(fresh, loaded);
+    if (isNewerParentState(fresh, loaded)) {
+      loaded.setExecutionState(fresh.peekExecutionState());
+    }
+  }
+
+  /**
+   * Read the stored document again before a loaded copy is written. A 
location can override this
+   * when a direct read sees a newer write than {@link 
#loadCacheEntry(String)}.
+   */
+  protected CacheEntry loadFreshCacheEntry(CacheEntry loaded) throws 
HopException {
+    if (loaded == null || StringUtils.isEmpty(loaded.getId())) {
+      return null;
+    }
+    return loadCacheEntry(loaded.getId());
+  }
+
+  /** Children stored by the other writer stay on the document this location 
is about to save. */
+  private static void copyMissingChildren(CacheEntry from, CacheEntry into) {
+    if (from == null || into == null || from == into) {
+      return;
+    }
+    copyMissingExecutions(from.getChildExecutions(), 
into.getChildExecutions());
+    copyMissing(from.getChildExecutionStates(), 
into.getChildExecutionStates());
+    copyMissing(from.getChildExecutionData(), into.getChildExecutionData());
+  }
+
+  private static void copyMissingExecutions(
+      Map<String, Execution> from, Map<String, Execution> into) {
+    if (from == null || from.isEmpty() || into == null) {
+      return;
+    }
+    for (Map.Entry<String, Execution> child : from.entrySet()) {
+      Execution stored = child.getValue();
+      Execution kept = into.putIfAbsent(child.getKey(), stored);
+      if (kept != null && stored != null) {
+        kept.unionChildExecutionIds(stored);
+      }
+    }
+  }
+
+  private static <K, V> void copyMissing(Map<K, V> from, Map<K, V> into) {
+    if (from == null || from.isEmpty() || into == null) {
+      return;
+    }
+    for (Map.Entry<K, V> child : from.entrySet()) {
+      into.putIfAbsent(child.getKey(), child.getValue());
+    }
+  }
+
+  /**
+   * Re-read a parent that was loaded only to record a child. When the stored 
copy is newer, move
+   * the launcher and the child id onto that copy so the write does not 
replace a finished state.
+   */
+  CacheEntry keepNewerParentState(CacheEntry loaded, Execution child) throws 
HopException {
+    CacheEntry fresh = loadFreshCacheEntry(loaded);
+    if (fresh == null || fresh.getExecution() == null || 
!isNewerParentState(fresh, loaded)) {
+      if (fresh != null
+          && StringUtils.isEmpty(loaded.getInternalId())
+          && StringUtils.isNotEmpty(fresh.getInternalId())) {
+        loaded.setInternalId(fresh.getInternalId());
+      }
+      return loaded;
+    }
+    Execution launcher = fresh.peekChildExecution(child.getLaunchedById());
+    if (launcher == null) {
+      launcher = minimalLauncher(child, fresh.getExecution());
+      fresh.addChildExecution(launcher);
+    }
+    launcher.addChildExecutionId(child.getId());
+    fresh.setDirty(true);
+    return fresh;
+  }
+
+  /**
+   * A stored parent is newer when it has finished and the loaded copy has 
not, or when its update
+   * time is later. A finished copy is never replaced by an unfinished one. A 
child location uses
+   * this so it does not write an older execution state back.
+   */
+  static boolean isNewerParentState(CacheEntry fresh, CacheEntry loaded) {
+    ExecutionState freshState = fresh == null ? null : 
fresh.peekExecutionState();
+    ExecutionState loadedState = loaded == null ? null : 
loaded.peekExecutionState();
+    if (freshState == null) {
+      return false;
+    }
+    if (loadedState == null) {
+      return true;
+    }
+    if (freshState.getExecutionEndDate() != null && 
loadedState.getExecutionEndDate() == null) {
+      return true;
+    }
+    // An unfinished snapshot must not replace a finished one, even when its 
update time is later.
+    if (loadedState.getExecutionEndDate() != null && 
freshState.getExecutionEndDate() == null) {
+      return false;
+    }
+    Date freshUpdate = freshState.getUpdateTime();
+    Date loadedUpdate = loadedState.getUpdateTime();
+    if (freshUpdate == null) {
+      return false;
+    }
+    return loadedUpdate == null || freshUpdate.after(loadedUpdate);
+  }
+
   /**
    * An action or transform that has not been flushed yet. The id and the 
child link are enough for
    * drill-down. The parent's next save replaces this with the real launcher 
and keeps the child id.
@@ -509,6 +650,7 @@ public abstract class BaseCachingExecutionInfoLocation 
implements IExecutionInfo
       entry.setId(execution.getId());
     }
     entry.setExecution(execution);
+    entry.setLoadedCopy(false);
     entry.setName(execution.getName());
     if (StringUtils.isNotEmpty(execution.getProjectId())) {
       entry.setProjectId(execution.getProjectId());
@@ -718,6 +860,9 @@ public abstract class BaseCachingExecutionInfoLocation 
implements IExecutionInfo
       entry.setLastRead(new Date());
       entry.setLastWritten(new Date());
       entry.setDirty(false);
+      // Registered executions are created in this cache. A loaded document 
belongs to another
+      // location until this one registers it, so a later save has to re-read 
the state.
+      entry.setLoadedCopy(true);
 
       cache.put(entry.getId(), entry);
       enforceMaxCacheSize();
@@ -769,7 +914,7 @@ public abstract class BaseCachingExecutionInfoLocation 
implements IExecutionInfo
       // A local single-writer flushes from the cache timer. Another process 
(Spark/Beam) still
       // needs the samples on disk before it merges its own write.
       if (!entry.isSingleWriter()) {
-        persistCacheEntry(entry);
+        saveCacheEntry(entry);
       }
     } else {
       LogChannel.GENERAL.logError(
diff --git 
a/engine/src/main/java/org/apache/hop/execution/caching/CacheEntry.java 
b/engine/src/main/java/org/apache/hop/execution/caching/CacheEntry.java
index eae0067049..0915636233 100644
--- a/engine/src/main/java/org/apache/hop/execution/caching/CacheEntry.java
+++ b/engine/src/main/java/org/apache/hop/execution/caching/CacheEntry.java
@@ -98,6 +98,12 @@ public class CacheEntry {
    */
   @JsonIgnore private boolean singleWriter;
 
+  /**
+   * Set when a location loads this document from storage. A save re-reads so 
the loaded execution
+   * state does not replace a newer stored state.
+   */
+  @JsonIgnore private boolean loadedCopy;
+
   /** The document with metadata and pipeline XML has been inserted. */
   @JsonIgnore private boolean heavyDocumentStored;
 
diff --git 
a/engine/src/test/java/org/apache/hop/execution/caching/BaseCachingExecutionInfoLocationTest.java
 
b/engine/src/test/java/org/apache/hop/execution/caching/BaseCachingExecutionInfoLocationTest.java
index 6bbba58d8d..0f2f83d906 100644
--- 
a/engine/src/test/java/org/apache/hop/execution/caching/BaseCachingExecutionInfoLocationTest.java
+++ 
b/engine/src/test/java/org/apache/hop/execution/caching/BaseCachingExecutionInfoLocationTest.java
@@ -18,13 +18,17 @@
 package org.apache.hop.execution.caching;
 
 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.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.util.ArrayList;
 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.exception.HopException;
@@ -207,6 +211,160 @@ class BaseCachingExecutionInfoLocationTest {
     assertTrue(wfChildren.stream().anyMatch(e -> e.getId().equals(actionId)));
   }
 
+  @Test
+  void childLinkKeepsANewerStoredParentState() throws Exception {
+    FakeLocation location = new FakeLocation();
+    CacheEntry running = parentEntry("wf", "running", new Date(1_000L), null);
+    CacheEntry finished = parentEntry("wf", "Finished", new Date(5_000L), new 
Date(5_000L));
+    finished.setInternalId("doc-9");
+    location.scriptedLoads.add(running);
+    location.scriptedLoads.add(finished);
+
+    location.registerExecution(launchedPipeline("pipe", "wf", "action"));
+
+    assertNull(location.getCache().get("wf"));
+    assertSame(finished, location.lastPersisted);
+    assertEquals("Finished", 
location.lastPersisted.getExecutionState().getStatusDescription());
+    
assertNotNull(location.lastPersisted.getExecutionState().getExecutionEndDate());
+    assertEquals("doc-9", location.lastPersisted.getInternalId());
+    Execution launcher = location.lastPersisted.peekChildExecution("action");
+    assertNotNull(launcher);
+    assertEquals(List.of("pipe"), launcher.getChildExecutionIds());
+  }
+
+  @Test
+  void childLinkKeepsTheLoadedParentWhenTheStoredCopyIsNotNewer() throws 
Exception {
+    FakeLocation location = new FakeLocation();
+    CacheEntry loaded = parentEntry("wf", "Finished", new Date(5_000L), new 
Date(5_000L));
+    CacheEntry stored = parentEntry("wf", "Finished", new Date(5_000L), new 
Date(5_000L));
+    stored.setInternalId("doc-1");
+    location.scriptedLoads.add(loaded);
+    location.scriptedLoads.add(stored);
+
+    location.registerExecution(launchedPipeline("pipe", "wf", "action"));
+
+    assertSame(loaded, location.lastPersisted);
+    assertEquals("doc-1", location.lastPersisted.getInternalId());
+    assertEquals(
+        List.of("pipe"),
+        
location.lastPersisted.peekChildExecution("action").getChildExecutionIds());
+  }
+
+  @Test
+  void childLinkDoesNotReplaceAParentAlreadyInThisLocation() throws Exception {
+    FakeLocation location = new FakeLocation();
+    Execution workflow = pipeline("wf", "Workflow");
+    workflow.setExecutionType(ExecutionType.Workflow);
+    location.registerExecution(workflow);
+    ExecutionState running = new ExecutionState();
+    running.setId("wf");
+    running.setExecutionType(ExecutionType.Workflow);
+    running.setStatusDescription("running");
+    running.setUpdateTime(new Date(1_000L));
+    location.updateExecutionState(running);
+
+    CacheEntry finished = parentEntry("wf", "Finished", new Date(9_000L), new 
Date(9_000L));
+    location.scriptedLoads.add(finished);
+    int loadsBeforeChild = location.loadCount;
+
+    location.registerExecution(launchedPipeline("pipe", "wf", "action"));
+
+    assertEquals(loadsBeforeChild, location.loadCount);
+    assertEquals("running", 
location.getExecutionState("wf").getStatusDescription());
+    assertEquals(List.of("pipe"), 
location.getExecution("action").getChildExecutionIds());
+  }
+
+  @Test
+  void attachOnlyLocationKeepsAFinishedStateWhenItLoadedARunningCopy() throws 
Exception {
+    FakeLocation attach = new FakeLocation();
+    CacheEntry running = parentEntry("wf", "running", new Date(9_000L), null);
+    running.setInternalId("doc-1");
+    CacheEntry finished = parentEntry("wf", "Finished", new Date(1_000L), new 
Date(5_000L));
+    finished.setInternalId("doc-1");
+    finished.getExecutionState().setLoggingText("done");
+    finished.addChildExecution(action("kept-action"));
+    attach.scriptedLoads.add(running);
+    attach.scriptedLoads.add(finished);
+
+    attach.registerExecution(action("action"));
+    assertTrue(attach.getCache().get("wf").isLoadedCopy());
+    assertEquals(1, attach.loadCount);
+
+    attach.close();
+
+    assertEquals(2, attach.loadCount);
+    assertEquals("Finished", 
attach.lastPersisted.getExecutionState().getStatusDescription());
+    
assertNotNull(attach.lastPersisted.getExecutionState().getExecutionEndDate());
+    assertEquals("done", 
attach.lastPersisted.getExecutionState().getLoggingText());
+    assertEquals("doc-1", attach.lastPersisted.getInternalId());
+    assertNotNull(attach.lastPersisted.peekChildExecution("action"));
+    assertNotNull(attach.lastPersisted.peekChildExecution("kept-action"));
+  }
+
+  @Test
+  void attachOnlyLocationKeepsAFinishedStateWhenItLoadedBeforeAnyState() 
throws Exception {
+    FakeLocation attach = new FakeLocation();
+    CacheEntry loaded = new CacheEntry();
+    loaded.setId("wf");
+    loaded.setName("wf");
+    Execution workflow = new Execution();
+    workflow.setId("wf");
+    workflow.setName("wf");
+    workflow.setExecutionType(ExecutionType.Workflow);
+    workflow.setRegistrationDate(new Date(1_000L));
+    loaded.setExecution(workflow);
+    loaded.setInternalId("doc-2");
+    CacheEntry finished = parentEntry("wf", "Finished", new Date(5_000L), new 
Date(5_000L));
+    finished.setInternalId("doc-2");
+    attach.scriptedLoads.add(loaded);
+    attach.scriptedLoads.add(finished);
+
+    attach.registerExecution(action("action"));
+    attach.close();
+
+    assertEquals("Finished", 
attach.lastPersisted.getExecutionState().getStatusDescription());
+    
assertNotNull(attach.lastPersisted.getExecutionState().getExecutionEndDate());
+    assertNotNull(attach.lastPersisted.peekChildExecution("action"));
+  }
+
+  @Test
+  void registeringLocationWritesItsOwnFinishedStateWithoutReloading() throws 
Exception {
+    FakeLocation location = new FakeLocation();
+    Execution workflow = pipeline("wf", "Workflow");
+    workflow.setExecutionType(ExecutionType.Workflow);
+    location.registerExecution(workflow);
+    ExecutionState finished = new ExecutionState();
+    finished.setId("wf");
+    finished.setExecutionType(ExecutionType.Workflow);
+    finished.setStatusDescription("Finished");
+    finished.setUpdateTime(new Date(5_000L));
+    finished.setExecutionEndDate(new Date(5_000L));
+    location.updateExecutionState(finished);
+
+    location.close();
+
+    assertEquals(0, location.loadCount);
+    assertEquals("Finished", 
location.lastPersisted.getExecutionState().getStatusDescription());
+    
assertNotNull(location.lastPersisted.getExecutionState().getExecutionEndDate());
+  }
+
+  @Test
+  void aFinishedParentStateIsNewerThanARunningOne() {
+    CacheEntry running = parentEntry("wf", "running", new Date(9_000L), null);
+    CacheEntry finished = parentEntry("wf", "Finished", new Date(1_000L), new 
Date(1_000L));
+
+    assertTrue(BaseCachingExecutionInfoLocation.isNewerParentState(finished, 
running));
+    assertFalse(BaseCachingExecutionInfoLocation.isNewerParentState(running, 
finished));
+    assertTrue(
+        BaseCachingExecutionInfoLocation.isNewerParentState(
+            parentEntry("wf", "running", new Date(9_000L), null),
+            parentEntry("wf", "running", new Date(1_000L), null)));
+    assertFalse(
+        BaseCachingExecutionInfoLocation.isNewerParentState(
+            parentEntry("wf", "running", new Date(1_000L), null),
+            parentEntry("wf", "running", new Date(1_000L), null)));
+  }
+
   @Test
   void testAddExecutionDataRetainsNonEmptySamples() {
     CacheEntry entry = new CacheEntry();
@@ -244,6 +402,42 @@ class BaseCachingExecutionInfoLocationTest {
     assertEquals(1, 
entry.getExecutionData("all-transforms").getDataSets().get("set1").size());
   }
 
+  private static Execution action(String id) {
+    Execution execution = new Execution();
+    execution.setId(id);
+    execution.setName(id);
+    execution.setParentId("wf");
+    execution.setExecutionType(ExecutionType.Action);
+    execution.setRegistrationDate(new Date(1_000L));
+    return execution;
+  }
+
+  private static Execution launchedPipeline(String id, String parentId, String 
launchedById) {
+    Execution execution = pipeline(id, id);
+    execution.setParentId(parentId);
+    execution.setLaunchedById(launchedById);
+    return execution;
+  }
+
+  private static CacheEntry parentEntry(String id, String status, Date 
updateTime, Date endDate) {
+    Execution execution = new Execution();
+    execution.setId(id);
+    execution.setName(id);
+    execution.setExecutionType(ExecutionType.Workflow);
+    execution.setRegistrationDate(new Date(1_000L));
+    CacheEntry entry = new CacheEntry();
+    entry.setId(id);
+    entry.setExecution(execution);
+    ExecutionState state = new ExecutionState();
+    state.setId(id);
+    state.setExecutionType(ExecutionType.Workflow);
+    state.setStatusDescription(status);
+    state.setUpdateTime(updateTime);
+    state.setExecutionEndDate(endDate);
+    entry.setExecutionState(state);
+    return entry;
+  }
+
   private static Execution pipeline(String id, String name) {
     Execution execution = new Execution();
     execution.setId(id);
@@ -266,6 +460,10 @@ class BaseCachingExecutionInfoLocationTest {
   private static final class FakeLocation extends 
BaseCachingExecutionInfoLocation {
     private boolean failPersist;
     private final Set<String> persisted = new HashSet<>();
+    private final List<CacheEntry> scriptedLoads = new ArrayList<>();
+    private int scriptedLoadIndex;
+    private CacheEntry lastPersisted;
+    private int loadCount;
     private String pluginId;
     private String pluginName;
 
@@ -300,13 +498,18 @@ class BaseCachingExecutionInfoLocationTest {
         throw new HopException("persist failed for " + cacheEntry.getId());
       }
       persisted.add(cacheEntry.getId());
+      lastPersisted = cacheEntry;
       cacheEntry.setDirty(false);
       cacheEntry.setLastWritten(new Date());
     }
 
     @Override
     protected CacheEntry loadCacheEntry(String executionId) {
-      return null;
+      loadCount++;
+      if (scriptedLoadIndex >= scriptedLoads.size()) {
+        return null;
+      }
+      return scriptedLoads.get(scriptedLoadIndex++);
     }
 
     @Override
diff --git a/integration-tests/mail/main-0001-james-rest.hwf 
b/integration-tests/mail/main-0001-james-rest.hwf
index 15dc86dea2..06c582d847 100644
--- a/integration-tests/mail/main-0001-james-rest.hwf
+++ b/integration-tests/mail/main-0001-james-rest.hwf
@@ -63,7 +63,21 @@ limitations under the License.
       <loglevel>Basic</loglevel>
       <script>#!/bin/bash 
 
-echo "james server: " ${MAIL_SMTP_SERVER} 
+echo "james server: ${MAIL_SMTP_SERVER}"
+ready=0
+attempt=0
+while [ "$attempt" -lt 60 ]; do
+  attempt=$((attempt + 1))
+  if curl -sf -o /dev/null --max-time 2 
"http://${MAIL_SMTP_SERVER}:8000/domains"; 2>/dev/null; then
+    ready=1
+    break
+  fi
+  sleep 2
+done
+if [ "$ready" != 1 ]; then
+  echo "James WebAdmin did not accept connections on port 8000"
+  exit 1
+fi
 
 curl -XPUT http://${MAIL_SMTP_SERVER}:8000/domains/example.com
 curl -XPUT http://${MAIL_SMTP_SERVER}:8000/domains/acme.be
@@ -114,18 +128,6 @@ curl -XPUT 
http://${MAIL_SMTP_SERVER}:8000/users/[email protected]   -d '{"pass
       <yloc>64</yloc>
       <attributes_hac/>
     </action>
-    <action>
-      <name>wait for 30s</name>
-      <description/>
-      <type>DELAY</type>
-      <attributes/>
-      <maximumTimeout>30</maximumTimeout>
-      <scaletime>0</scaletime>
-      <parallel>N</parallel>
-      <xloc>224</xloc>
-      <yloc>64</yloc>
-      <attributes_hac/>
-    </action>
     <action>
       <name>get mails</name>
       <description/>
@@ -232,17 +234,10 @@ curl -XPUT 
http://${MAIL_SMTP_SERVER}:8000/users/[email protected]   -d '{"pass
     </hop>
     <hop>
       <from>Start</from>
-      <to>wait for 30s</to>
-      <enabled>Y</enabled>
-      <evaluation>Y</evaluation>
-      <unconditional>Y</unconditional>
-    </hop>
-    <hop>
-      <from>wait for 30s</from>
       <to>add domains, users</to>
       <enabled>Y</enabled>
       <evaluation>Y</evaluation>
-      <unconditional>N</unconditional>
+      <unconditional>Y</unconditional>
     </hop>
     <hop>
       <from>get mails</from>
diff --git a/integration-tests/mail/main-0009-mail-with-zip.hwf 
b/integration-tests/mail/main-0009-mail-with-zip.hwf
index ed51945922..27eb98a477 100644
--- a/integration-tests/mail/main-0009-mail-with-zip.hwf
+++ b/integration-tests/mail/main-0009-mail-with-zip.hwf
@@ -139,7 +139,21 @@ limitations under the License.
       <loglevel>Basic</loglevel>
       <script>#!/bin/bash 
 
-echo "james server: " ${MAIL_SMTP_SERVER} 
+echo "james server: ${MAIL_SMTP_SERVER}"
+ready=0
+attempt=0
+while [ "$attempt" -lt 60 ]; do
+  attempt=$((attempt + 1))
+  if curl -sf -o /dev/null --max-time 2 
"http://${MAIL_SMTP_SERVER}:8000/domains"; 2>/dev/null; then
+    ready=1
+    break
+  fi
+  sleep 2
+done
+if [ "$ready" != 1 ]; then
+  echo "James WebAdmin did not accept connections on port 8000"
+  exit 1
+fi
 
 curl -XPUT http://${MAIL_SMTP_SERVER}:8000/domains/example.com
 curl -XPUT http://${MAIL_SMTP_SERVER}:8000/domains/acme.be
diff --git 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
index 5b87e0d4d9..abf952a2d0 100644
--- 
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
+++ 
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocation.java
@@ -500,16 +500,24 @@ public class NeoExecutionInfoLocation implements 
IExecutionInfoLocation {
               .withValue(EP_EXECUTION_START_DATE, 
execution.getExecutionStartDate());
       execute(transaction, builder);
 
-      if (StringUtils.isNotEmpty(execution.getParentId())) {
+      // The action or transform that started this execution. Link that node 
when it is already
+      // stored, and do not also link the owner: shortestPath would skip the 
launcher. A missing
+      // launcher keeps the owner edge so the child is not orphaned.
+      boolean launcherStored =
+          StringUtils.isNotEmpty(execution.getLaunchedById())
+              && !execution.getLaunchedById().equals(execution.getParentId())
+              && executionNodeExists(transaction, execution.getLaunchedById());
+      if (linkOwnerExecutes(execution.getParentId(), 
execution.getLaunchedById(), launcherStored)) {
         linkExecutes(transaction, execution.getParentId(), execution.getId());
       }
-      // The action or transform that started this execution. Link it when 
that node is already
-      // stored. A missing launcher must not roll back the execution itself.
-      if (StringUtils.isNotEmpty(execution.getLaunchedById())
-          && !execution.getLaunchedById().equals(execution.getParentId())
-          && executionNodeExists(transaction, execution.getLaunchedById())) {
+      if (launcherStored) {
         linkExecutes(transaction, execution.getLaunchedById(), 
execution.getId());
       }
+      // A launcher stored after its child still has only the owner shortcut. 
Move EXECUTES onto
+      // the launcher now that its node exists.
+      if (StringUtils.isNotEmpty(execution.getId())) {
+        run(transaction, relinkLaunchedChildrenCypher(), Map.of("id", 
execution.getId()));
+      }
 
       // See if we need to cache this
       //
@@ -523,6 +531,61 @@ public class NeoExecutionInfoLocation implements 
IExecutionInfoLocation {
     }
   }
 
+  /**
+   * Link {@code (parent)-[:EXECUTES]->(child)} only when the launcher was not 
linked. The owner
+   * shortcut and the launcher edge together make {@code shortestPath} skip 
the action or transform.
+   */
+  static boolean linkOwnerExecutes(
+      String parentId, String launchedById, boolean launcherNodeExists) {
+    if (StringUtils.isEmpty(parentId)) {
+      return false;
+    }
+    if (StringUtils.isEmpty(launchedById) || launchedById.equals(parentId)) {
+      return true;
+    }
+    return !launcherNodeExists;
+  }
+
+  /**
+   * Children already stored with {@code launchedById} equal to this execution 
get an EXECUTES edge
+   * from this node, and the owner shortcut is removed. Actions have no {@code 
launchedById}, so
+   * {@code (workflow)-[:EXECUTES]->(action)} stays.
+   */
+  static String relinkLaunchedChildrenCypher() {
+    return "MATCH (launcher:"
+        + EL_EXECUTION
+        + " {"
+        + EP_ID
+        + ": $id}) "
+        + "MATCH (child:"
+        + EL_EXECUTION
+        + ") "
+        + "WHERE child."
+        + EP_LAUNCHED_BY_ID
+        + " = $id "
+        + "AND child."
+        + EP_PARENT_ID
+        + " IS NOT NULL "
+        + "AND child."
+        + EP_PARENT_ID
+        + " <> $id "
+        + "MERGE (launcher)-[:"
+        + R_EXECUTES
+        + "]->(child) "
+        + "WITH child "
+        + "MATCH (owner:"
+        + EL_EXECUTION
+        + ")-[shortcut:"
+        + R_EXECUTES
+        + "]->(child) "
+        + "WHERE owner."
+        + EP_ID
+        + " = child."
+        + EP_PARENT_ID
+        + " "
+        + "DELETE shortcut";
+  }
+
   /** {@code (from)-[:EXECUTES]->(to)} when both nodes are already stored. */
   private void linkExecutes(IGraphTransaction transaction, String fromId, 
String toId) {
     execute(
diff --git 
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocationDeleteTest.java
 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocationDeleteTest.java
index 2be8c7f0db..8de8d31092 100644
--- 
a/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocationDeleteTest.java
+++ 
b/plugins/tech/neo4j/src/test/java/org/apache/hop/neo4j/execution/NeoExecutionInfoLocationDeleteTest.java
@@ -58,6 +58,26 @@ class NeoExecutionInfoLocationDeleteTest {
     assertEquals("action-1", query.parameters().get("executionId"));
   }
 
+  @Test
+  void ownerEdgeIsSkippedWhenTheLauncherNodeIsLinked() {
+    assertFalse(NeoExecutionInfoLocation.linkOwnerExecutes("workflow", 
"action", true));
+    assertTrue(NeoExecutionInfoLocation.linkOwnerExecutes("workflow", 
"action", false));
+    assertTrue(NeoExecutionInfoLocation.linkOwnerExecutes("workflow", null, 
false));
+    assertTrue(NeoExecutionInfoLocation.linkOwnerExecutes("workflow", 
"workflow", true));
+    assertFalse(NeoExecutionInfoLocation.linkOwnerExecutes(null, "action", 
false));
+  }
+
+  @Test
+  void relinkMovesExecutesFromTheOwnerToTheLauncher() {
+    String cypher = NeoExecutionInfoLocation.relinkLaunchedChildrenCypher();
+
+    assertTrue(cypher.contains("launchedById = $id"));
+    assertTrue(cypher.contains("MERGE (launcher)-[:EXECUTES]->(child)"));
+    assertTrue(cypher.contains("WHERE owner.id = child.parentId"));
+    assertTrue(cypher.contains("DELETE shortcut"));
+    assertTrue(cypher.contains("child.parentId <> $id"));
+  }
+
   @Test
   void batchDeleteLimitsEachTransaction() {
     String cypher =
diff --git 
a/plugins/tech/opensearch/src/main/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocation.java
 
b/plugins/tech/opensearch/src/main/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocation.java
index 0fd1499831..c519e53d84 100644
--- 
a/plugins/tech/opensearch/src/main/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocation.java
+++ 
b/plugins/tech/opensearch/src/main/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocation.java
@@ -19,6 +19,7 @@
 package org.apache.hop.execution.opensearch;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
 import java.net.URI;
 import java.text.SimpleDateFormat;
 import java.time.ZoneId;
@@ -183,13 +184,7 @@ public class OpenSearchExecutionInfoLocation extends 
BaseCachingExecutionInfoLoc
       cacheEntry.calculateSummary();
 
       URI uri = URI.create(actualUrl);
-      URI postUri;
-
-      if (StringUtils.isEmpty(cacheEntry.getInternalId())) {
-        postUri = uri.resolve(actualIndexName + "/_doc");
-      } else {
-        postUri = uri.resolve(actualIndexName + "/_doc/" + 
cacheEntry.getInternalId());
-      }
+      URI postUri = uri.resolve(documentPath(actualIndexName, 
cacheEntry.getInternalId()));
 
       ObjectMapper mapper = new ObjectMapper();
       String body = mapper.writeValueAsString(cacheEntry);
@@ -404,11 +399,7 @@ public class OpenSearchExecutionInfoLocation extends 
BaseCachingExecutionInfoLoc
         return null;
       }
 
-      // Convert this source object to CacheEntry
-      //
-      String json = jSource.toJSONString();
-      ObjectMapper objectMapper = new ObjectMapper(HopJson.newFactory());
-      return objectMapper.readValue(json, CacheEntry.class);
+      return cacheEntryFromSearchHit(jHit);
     } catch (Exception e) {
       throw new HopException(
           "Error loading execution information location from OpenSearch using 
executionId '"
@@ -418,6 +409,55 @@ public class OpenSearchExecutionInfoLocation extends 
BaseCachingExecutionInfoLoc
     }
   }
 
+  /**
+   * Re-read by document id. {@code GET index/_doc/id} returns the latest 
write. A search can still
+   * return the document from before that write.
+   */
+  @Override
+  protected CacheEntry loadFreshCacheEntry(CacheEntry loaded) throws 
HopException {
+    if (loaded != null && StringUtils.isNotEmpty(loaded.getInternalId())) {
+      CacheEntry byDocumentId = 
loadCacheEntryByDocumentId(loaded.getInternalId());
+      if (byDocumentId != null) {
+        return byDocumentId;
+      }
+    }
+    return super.loadFreshCacheEntry(loaded);
+  }
+
+  private CacheEntry loadCacheEntryByDocumentId(String internalId) throws 
HopException {
+    try {
+      URI uri = URI.create(actualUrl);
+      URI getUri = uri.resolve(documentGetPath(actualIndexName, internalId));
+      RestCaller restCaller =
+          new RestCaller(
+              metadataProvider,
+              getUri.toString(),
+              actualUsername,
+              actualPassword,
+              "GET",
+              "",
+              ignoreSsl,
+              getHeaders());
+      String responseBody = restCaller.execute();
+      Long statusCode = restCaller.getStatusCode();
+      if (statusCode != null && statusCode.longValue() == 404L) {
+        return null;
+      }
+      checkStatusCode(responseBody, statusCode, 200L);
+      JSONParser parser = new JSONParser();
+      JSONObject body = (JSONObject) parser.parse(responseBody);
+      if (body == null || Boolean.FALSE.equals(body.get("found"))) {
+        return null;
+      }
+      return cacheEntryFromSearchHit(body);
+    } catch (HopException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new HopException(
+          "Error loading execution information from OpenSearch document '" + 
internalId + "'", e);
+    }
+  }
+
   @Override
   protected void retrieveIds(
       boolean includeChildren, Set<DatedId> ids, int limit, IExecutionSelector 
selector)
@@ -564,6 +604,47 @@ public class OpenSearchExecutionInfoLocation extends 
BaseCachingExecutionInfoLoc
     return indexName + "/_refresh";
   }
 
+  /**
+   * Document path for a create or update. A later re-read uses {@link 
#documentGetPath} so the
+   * latest write is visible without waiting for a refresh on this request.
+   */
+  static String documentPath(String indexName, String internalId) {
+    String path = indexName + "/_doc";
+    if (StringUtils.isNotEmpty(internalId)) {
+      path = path + "/" + internalId;
+    }
+    return path;
+  }
+
+  /**
+   * Realtime read of one document. The get-by-id API returns the latest 
stored source, including a
+   * write that search has not refreshed yet.
+   */
+  static String documentGetPath(String indexName, String internalId) {
+    return indexName + "/_doc/" + internalId;
+  }
+
+  /**
+   * {@code internalId} is not part of {@code _source}. The search hit {@code 
_id} is what a later
+   * update must post to, or the next save creates a second document.
+   */
+  static CacheEntry cacheEntryFromSearchHit(JSONObject hit) throws IOException 
{
+    if (hit == null) {
+      return null;
+    }
+    JSONObject source = (JSONObject) hit.get("_source");
+    if (source == null) {
+      return null;
+    }
+    ObjectMapper objectMapper = new ObjectMapper(HopJson.newFactory());
+    CacheEntry entry = objectMapper.readValue(source.toJSONString(), 
CacheEntry.class);
+    Object docId = hit.get("_id");
+    if (docId != null) {
+      entry.setInternalId(String.valueOf(docId));
+    }
+    return entry;
+  }
+
   @Override
   public int deleteExecutions(Date olderThan, IProgressMonitor monitor) throws 
HopException {
     int deleted = 0;
diff --git 
a/plugins/tech/opensearch/src/test/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocationCleanupTest.java
 
b/plugins/tech/opensearch/src/test/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocationCleanupTest.java
index 010cac4033..fae945c94b 100644
--- 
a/plugins/tech/opensearch/src/test/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocationCleanupTest.java
+++ 
b/plugins/tech/opensearch/src/test/java/org/apache/hop/execution/opensearch/OpenSearchExecutionInfoLocationCleanupTest.java
@@ -19,10 +19,13 @@ package org.apache.hop.execution.opensearch;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.util.Date;
 import java.util.TimeZone;
+import org.apache.hop.execution.caching.CacheEntry;
+import org.json.simple.JSONObject;
 import org.junit.jupiter.api.Test;
 
 class OpenSearchExecutionInfoLocationCleanupTest {
@@ -47,6 +50,48 @@ class OpenSearchExecutionInfoLocationCleanupTest {
         "hop-executions/_refresh", 
OpenSearchExecutionInfoLocation.refreshPath("hop-executions"));
   }
 
+  @Test
+  void documentPathNamesTheDocumentWithoutWaitingForRefresh() {
+    assertEquals("hop/_doc", 
OpenSearchExecutionInfoLocation.documentPath("hop", null));
+    assertEquals("hop/_doc", 
OpenSearchExecutionInfoLocation.documentPath("hop", ""));
+    assertEquals("hop/_doc/abc", 
OpenSearchExecutionInfoLocation.documentPath("hop", "abc"));
+  }
+
+  @Test
+  void documentGetPathReadsOneDocumentById() {
+    assertEquals("hop/_doc/abc", 
OpenSearchExecutionInfoLocation.documentGetPath("hop", "abc"));
+  }
+
+  @Test
+  void searchHitKeepsTheOpenSearchDocumentId() throws Exception {
+    JSONObject source = new JSONObject();
+    source.put("id", "exec-1");
+    source.put("name", "run");
+    JSONObject hit = new JSONObject();
+    hit.put("_id", "os-doc-7");
+    hit.put("_source", source);
+
+    CacheEntry entry = 
OpenSearchExecutionInfoLocation.cacheEntryFromSearchHit(hit);
+
+    assertEquals("exec-1", entry.getId());
+    assertEquals("run", entry.getName());
+    assertEquals("os-doc-7", entry.getInternalId());
+  }
+
+  @Test
+  void searchHitWithoutADocumentIdLeavesTheInternalIdEmpty() throws Exception {
+    JSONObject source = new JSONObject();
+    source.put("id", "exec-1");
+    JSONObject hit = new JSONObject();
+    hit.put("_source", source);
+
+    CacheEntry entry = 
OpenSearchExecutionInfoLocation.cacheEntryFromSearchHit(hit);
+
+    assertEquals("exec-1", entry.getId());
+    assertNull(entry.getInternalId());
+    assertNull(OpenSearchExecutionInfoLocation.cacheEntryFromSearchHit(null));
+  }
+
   @Test
   void cleanupSqlWithoutACutoffStillPages() {
     String sql = OpenSearchExecutionInfoLocation.cleanupSql("hop-executions", 
"", null, 200);

Reply via email to