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 7525d30ef8 harden VFS namespace split, fixes #8215 (#8231)
7525d30ef8 is described below

commit 7525d30ef8e818d9d306bca599531286c7ba1dfa
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Wed Sep 2 11:47:35 2026 +0200

    harden VFS namespace split, fixes #8215 (#8231)
---
 .../main/java/org/apache/hop/core/vfs/HopVfs.java  |  41 ++++--
 .../org/apache/hop/core/vfs/HopVfsNamespace.java   |   8 ++
 .../org/apache/hop/core/vfs/HopVfsNamespaces.java  |  13 ++
 .../hop/core/vfs/HopVfsNamespaceScopingTest.java   | 140 +++++++++++++++++++++
 .../java/org/apache/hop/pipeline/Pipeline.java     |   4 +-
 .../java/org/apache/hop/workflow/Workflow.java     |   4 +-
 6 files changed, 199 insertions(+), 11 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java 
b/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
index 2928896d9f..4787e127d3 100644
--- a/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
+++ b/core/src/main/java/org/apache/hop/core/vfs/HopVfs.java
@@ -105,7 +105,7 @@ public class HopVfs {
     // them, so start over. Nothing registered yet means we can keep the 
manager as it is: phase 2
     // simply runs with these variables the next time around.
     //
-    if (namedProvidersRegistered) {
+    if (namedProvidersRegistered && !HopVfsNamespaces.isIsolated()) {
       reset();
     }
   }
@@ -748,7 +748,9 @@ public class HopVfs {
    */
   public static boolean startsWithScheme(String vfsFileName, IVariables 
variables) {
     bootstrapWith(variables);
-    return startsWithScheme(vfsFileName);
+    // The schemes that apply are the ones of the namespace these variables 
resolve files in: the
+    // named connections of an export, or of a Hop Web session, are not on the 
process manager.
+    return startsWithScheme(vfsFileName, getFileSystemManager(variables));
   }
 
   /**
@@ -759,8 +761,13 @@ public class HopVfs {
    * @return boolean
    */
   public static boolean startsWithScheme(String vfsFileName) {
-    DefaultFileSystemManager fsManager = getFileSystemManager();
+    // Nothing to go on but the thread: the namespace of the execution running 
on it, if any.
+    HopVfsNamespace namespace = HopVfsNamespaces.getCurrent();
+    return startsWithScheme(
+        vfsFileName, namespace == null ? getFileSystemManager() : 
namespace.getFileSystemManager());
+  }
 
+  private static boolean startsWithScheme(String vfsFileName, 
DefaultFileSystemManager fsManager) {
     boolean found = false;
     String[] schemes = fsManager.getSchemes();
     for (String scheme : schemes) {
@@ -843,10 +850,24 @@ public class HopVfs {
   }
 
   /**
-   * Drop the file system manager so it's rebuilt, providers of the named VFS 
connections included,
-   * the next time it's used. The bootstrap variables are kept: use {@link
-   * #setBootstrapVariables(IVariables)} to change those.
+   * Let go of the file systems nobody is using any more in the namespace 
these variables resolve
+   * files in.
+   *
+   * <p>Use this rather than {@link #freeUnusedResources()} at the end of an 
execution: an execution
+   * carrying its own metadata resolved its files in a namespace of its own, 
and those are the file
+   * systems it is done with. The process wide manager belongs to whoever else 
is in this JVM.
+   *
+   * @param variables the variables of the execution that just ended
    */
+  public static void freeUnusedResources(IVariables variables) {
+    HopVfsNamespace namespace = HopVfsNamespaces.resolve(variables);
+    if (namespace != null) {
+      namespace.freeUnusedResources();
+      return;
+    }
+    freeUnusedResources();
+  }
+
   /**
    * Read the named VFS connections again for whoever these variables belong 
to, after one of them
    * was added or changed.
@@ -871,7 +892,13 @@ public class HopVfs {
           "Error re-reading the named VFS connections of this caller. They are 
unchanged.", e);
       return;
     }
-    // Nothing of their own to refresh: what they resolve files in is the 
process wide manager.
+    if (HopVfsNamespaces.isIsolated()) {
+      LogChannel.GENERAL.logDebug(
+          "No VFS namespace to re-read the named connections for. The process 
wide file system "
+              + "manager is left alone: other sessions are using it.");
+      return;
+    }
+    // What they resolve files in is the process wide manager.
     reset();
   }
 
diff --git a/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespace.java 
b/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespace.java
index eebbaccbaa..29ef06748c 100644
--- a/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespace.java
+++ b/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespace.java
@@ -123,6 +123,14 @@ public class HopVfsNamespace implements AutoCloseable {
     return description;
   }
 
+  /**
+   * Close the communication links of the file systems in this namespace that 
nothing is using any
+   * more. What an execution does when it ends, for its own namespace and 
nobody else's.
+   */
+  public void freeUnusedResources() {
+    fileSystemManager.freeUnusedResources();
+  }
+
   int retain() {
     return ++useCount;
   }
diff --git a/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespaces.java 
b/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespaces.java
index 66d9dc1542..71e3adae1e 100644
--- a/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespaces.java
+++ b/core/src/main/java/org/apache/hop/core/vfs/HopVfsNamespaces.java
@@ -80,6 +80,19 @@ public class HopVfsNamespaces {
   /** Set when the runtime serves several tenants at once, so nobody shares 
the process manager. */
   private static boolean isolateEverything;
 
+  /**
+   * Does this JVM serve several tenants at once - Hop Web, with a session 
scope installed?
+   *
+   * <p>What it really answers is whether anything process wide may be thrown 
away on behalf of one
+   * of them. It may not: every tenant has a namespace of its own and the ones 
that are not asking
+   * are in the middle of something.
+   *
+   * @return true when a runtime installed a scope of its own with {@link 
#setScope(IHopScope)}
+   */
+  public static synchronized boolean isIsolated() {
+    return isolateEverything;
+  }
+
   private HopVfsNamespaces() {
     // Utility class
   }
diff --git 
a/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceScopingTest.java 
b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceScopingTest.java
index 7748cd648e..2bda4bbed2 100644
--- a/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceScopingTest.java
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceScopingTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.core.vfs;
 
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -25,9 +26,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 
 import org.apache.commons.vfs2.impl.DefaultFileSystemManager;
+import org.apache.commons.vfs2.provider.ram.RamFileProvider;
 import org.apache.hop.core.logging.HopLogStore;
 import org.apache.hop.core.scope.IHopScope;
 import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
 import org.apache.hop.metadata.util.HopMetadataInstance;
 import org.junit.jupiter.api.AfterEach;
@@ -71,6 +74,16 @@ class HopVfsNamespaceScopingTest {
     return perSession;
   }
 
+  /** The variables of something running against this metadata, the way an 
execution's are. */
+  private Variables variablesOf(IHopMetadataProvider provider) {
+    return new Variables() {
+      @Override
+      public IHopMetadataProvider getMetadataProvider() {
+        return provider;
+      }
+    };
+  }
+
   /** Build the process wide manager the way loading a project does, from this 
metadata. */
   private void bootstrapProcessManagerFrom(MultiMetadataProvider provider) {
     HopMetadataInstance.setMetadataProvider(provider);
@@ -196,6 +209,133 @@ class HopVfsNamespaceScopingTest {
     }
   }
 
+  @Test
+  @DisplayName("Enabling a project in one session leaves the namespaces of the 
others alone")
+  void enablingAProjectDoesNotResetOtherSessions() {
+    // Enabling a project hands HopVfs the variables to find that project's 
named connections with
+    // (ProjectsUtil.enableProject). In Hop Web that happens once per session, 
and it must not take
+    // the file system out from under the sessions already running. See issue 
#8215.
+    MultiMetadataProvider sessionA = mock(MultiMetadataProvider.class);
+    MultiMetadataProvider sessionB = mock(MultiMetadataProvider.class);
+
+    installSessionScopes();
+    bootstrapProcessManagerFrom(sessionA);
+
+    HopVfsNamespace namespaceA = HopVfsNamespaces.acquire(new Variables(), 
sessionA, "session A");
+    HopVfsNamespace namespaceB = HopVfsNamespaces.acquire(new Variables(), 
sessionB, "session B");
+    try {
+      DefaultFileSystemManager managerOfA = namespaceA.getFileSystemManager();
+
+      // Session B switches project: new variables, pointing at the metadata 
of the new project.
+      HopVfs.setBootstrapVariables(Variables.getADefaultVariableSpace());
+
+      assertSame(2, HopVfsNamespaces.size(), "Neither session should have lost 
its namespace");
+      assertSame(
+          managerOfA,
+          namespaceA.getFileSystemManager(),
+          "Session A was in the middle of something and must not have lost its 
file system");
+      assertTrue(
+          managerOfA.hasProvider("file"),
+          "The file system manager of session A must still be open");
+    } finally {
+      HopVfsNamespaces.release(sessionA);
+      HopVfsNamespaces.release(sessionB);
+    }
+  }
+
+  @Test
+  @DisplayName("A session with no namespace of its own still does not reset 
the shared manager")
+  void refreshWithoutANamespaceLeavesTheOthersAlone() {
+    // Saving a VFS connection from a session that has not opened a project 
yet: there is no
+    // namespace to re-read the connections for, and emptying the process wide 
manager instead
+    // would take the sessions that do have one down with it. See issue #8215.
+    MultiMetadataProvider sessionA = mock(MultiMetadataProvider.class);
+
+    installSessionScopes();
+    bootstrapProcessManagerFrom(sessionA);
+
+    HopVfsNamespace namespaceA = HopVfsNamespaces.acquire(new Variables(), 
sessionA, "session A");
+    try {
+      DefaultFileSystemManager managerOfA = namespaceA.getFileSystemManager();
+
+      HopVfs.refresh(new Variables());
+
+      assertSame(1, HopVfsNamespaces.size(), "Session A should still have its 
namespace");
+      assertSame(managerOfA, namespaceA.getFileSystemManager());
+      assertTrue(managerOfA.hasProvider("file"), "The manager of session A 
must still be open");
+    } finally {
+      HopVfsNamespaces.release(sessionA);
+    }
+  }
+
+  @Test
+  @DisplayName("An execution frees the resources of its own namespace, not of 
the shared manager")
+  void freeingResourcesStaysInsideTheCallersNamespace() {
+    // What Pipeline and Workflow do when they end. The file systems they are 
done with are the
+    // ones in their own namespace: the process wide manager belongs to 
whoever else is in the JVM.
+    MultiMetadataProvider processMetadata = mock(MultiMetadataProvider.class);
+    bootstrapProcessManagerFrom(processMetadata);
+
+    MultiMetadataProvider exportedMetadata = mock(MultiMetadataProvider.class);
+    HopVfsNamespace namespace =
+        HopVfsNamespaces.acquire(new Variables(), exportedMetadata, "exported 
pipeline");
+    try {
+      CountingProvider ofTheExport = new CountingProvider();
+      CountingProvider ofEveryoneElse = new CountingProvider();
+      namespace.getFileSystemManager().addProvider("exportdrop", ofTheExport);
+      HopVfs.getFileSystemManager().addProvider("shareddrop", ofEveryoneElse);
+
+      HopVfs.freeUnusedResources(variablesOf(exportedMetadata));
+
+      assertSame(
+          1, ofTheExport.timesFreed, "The export is done with the files of its 
own namespace");
+      assertSame(0, ofEveryoneElse.timesFreed, "Nobody else's file systems are 
its business");
+    } catch (Exception e) {
+      throw new AssertionError("Unable to register a provider", e);
+    } finally {
+      HopVfsNamespaces.release(exportedMetadata);
+    }
+  }
+
+  /** A provider that says whether the manager it sits on was asked to free 
its resources. */
+  private static class CountingProvider extends RamFileProvider {
+    private int timesFreed;
+
+    @Override
+    public void freeUnusedResources() {
+      timesFreed++;
+      super.freeUnusedResources();
+    }
+  }
+
+  @Test
+  @DisplayName("A scheme is looked for in the namespace of the caller, not on 
the shared manager")
+  void startsWithSchemeAsksTheCallersNamespace() {
+    // The name of a named VFS connection is a scheme, and the connections of 
an export - or of a
+    // Hop Web session - live in a namespace of their own. Anything deciding 
whether a filename
+    // carries a scheme has to ask the manager that filename will be resolved 
on.
+    MultiMetadataProvider processMetadata = mock(MultiMetadataProvider.class);
+    bootstrapProcessManagerFrom(processMetadata);
+
+    MultiMetadataProvider exportedMetadata = mock(MultiMetadataProvider.class);
+    HopVfsNamespace namespace =
+        HopVfsNamespaces.acquire(new Variables(), exportedMetadata, "exported 
pipeline");
+    try {
+      namespace.getFileSystemManager().addProvider("salesdrop", new 
RamFileProvider());
+
+      assertTrue(
+          HopVfs.startsWithScheme("salesdrop://in/orders.csv", 
variablesOf(exportedMetadata)),
+          "The connection of the export is a scheme its own namespace knows");
+      assertFalse(
+          HopVfs.startsWithScheme("salesdrop://in/orders.csv", new 
Variables()),
+          "Nobody else sees the connections of this export");
+    } catch (Exception e) {
+      throw new AssertionError("Unable to register a provider on the 
namespace", e);
+    } finally {
+      HopVfsNamespaces.release(exportedMetadata);
+    }
+  }
+
   @Test
   @DisplayName("A scope that answers per session never falls back to what a 
pooled thread left")
   void aSessionNeverInheritsAnotherSessionsNamespace() throws Exception {
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 cd8ba66612..609e6a16f6 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
@@ -1467,8 +1467,8 @@ public abstract class Pipeline
           // Safe here: all transform threads have finished before this 
listener runs.
           cleanupRowSets();
 
-          // release unused vfs connections
-          HopVfs.freeUnusedResources();
+          // release unused vfs connections, of the namespace this pipeline 
resolved its files in
+          HopVfs.freeUnusedResources(this);
         };
     // This should always be done first so that the other listeners achieve a 
clean state to start
     // from (setFinished and
diff --git a/engine/src/main/java/org/apache/hop/workflow/Workflow.java 
b/engine/src/main/java/org/apache/hop/workflow/Workflow.java
index 07282b78a3..7785afdbe3 100644
--- a/engine/src/main/java/org/apache/hop/workflow/Workflow.java
+++ b/engine/src/main/java/org/apache/hop/workflow/Workflow.java
@@ -358,8 +358,8 @@ public abstract class Workflow extends Variables
         log.logBasic(BaseMessages.getString(PKG, CONST_WORKFLOW_FINISHED));
         fireExecutionFinishedListeners();
 
-        // release unused vfs connections
-        HopVfs.freeUnusedResources();
+        // release unused vfs connections, of the namespace this workflow 
resolved its files in
+        HopVfs.freeUnusedResources(this);
 
       } catch (HopException e) {
         result.setNrErrors(1);

Reply via email to