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 2c7a75714f fix deadlock at high concurrency, fixes #8455 (#8456)
2c7a75714f is described below
commit 2c7a75714f19c55beb01c4042595e1c15fc9b1b4
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Sep 18 18:35:59 2026 +0200
fix deadlock at high concurrency, fixes #8455 (#8456)
---
.../org/apache/hop/core/vfs/HopVfsNamespace.java | 43 ++-
.../org/apache/hop/core/vfs/HopVfsNamespaces.java | 148 ++++++--
.../hop/core/vfs/HopVfsNamespaceLockingTest.java | 401 +++++++++++++++++++++
integration-tests/hop_server/0019-child-delay.hpl | 108 ++++++
integration-tests/hop_server/0019-child-delay.hwf | 99 +++++
.../hop_server/0019-drive-200-workflows.hpl | 181 ++++++++++
.../main-0019-concurrent-exports-single-server.hwf | 168 +++++++++
7 files changed, 1108 insertions(+), 40 deletions(-)
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 29ef06748c..303795db74 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
@@ -21,6 +21,8 @@ import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
import org.apache.commons.vfs2.impl.DefaultFileSystemManager;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.logging.LogChannel;
@@ -56,6 +58,19 @@ public class HopVfsNamespace implements AutoCloseable {
/** How many executions are using this namespace; it closes when the last
one lets go. */
private int useCount;
+ /**
+ * Counted down once the named connections are registered. The namespace is
published to the
+ * registry before that happens, so the registering thread finds it when
reading metadata lands
+ * back in resolution; anybody else acquiring it waits here - outside every
lock - rather than
+ * resolving files before the connections are in.
+ */
+ private final CountDownLatch ready = new CountDownLatch(1);
+
+ /**
+ * How long a second user waits for a registration that is still going; see
{@link #awaitReady}.
+ */
+ private static final long READY_TIMEOUT_SECONDS = 300;
+
HopVfsNamespace(String description) throws HopException {
this.description = description;
this.fileSystemManager = HopVfs.createFileSystemManager();
@@ -100,7 +115,7 @@ public class HopVfsNamespace implements AutoCloseable {
*
* @throws HopException if the new manager cannot be created
*/
- void rebuild() throws HopException {
+ synchronized void rebuild() throws HopException {
DefaultFileSystemManager previous = fileSystemManager;
DefaultFileSystemManager fresh = HopVfs.createFileSystemManager();
try {
@@ -131,6 +146,32 @@ public class HopVfsNamespace implements AutoCloseable {
fileSystemManager.freeUnusedResources();
}
+ /** The named connections are in; see {@link #awaitReady()}. */
+ void markReady() {
+ ready.countDown();
+ }
+
+ /**
+ * Wait until whoever created this namespace has registered its named
connections. Returns at once
+ * for the creating thread itself and for anyone arriving afterwards; only a
second acquirer that
+ * overtakes the registration ever waits, and never for longer than the
registration takes.
+ * Bounded all the same: a registration that never finishes must not take
its waiters with it.
+ */
+ void awaitReady() {
+ try {
+ if (!ready.await(READY_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ LogChannel.GENERAL.logError(
+ "The named VFS connections of "
+ + description
+ + " were still being registered after "
+ + READY_TIMEOUT_SECONDS
+ + "s; resolving files without waiting for them");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
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 71e3adae1e..59c79b6ced 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
@@ -17,7 +17,9 @@
package org.apache.hop.core.vfs;
+import java.util.ArrayList;
import java.util.IdentityHashMap;
+import java.util.List;
import java.util.Map;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.logging.LogChannel;
@@ -39,6 +41,16 @@ import org.apache.hop.metadata.util.HopMetadataInstance;
* running a pipeline by filename.
*
* <p>See Apache Hop issue #8106.
+ *
+ * <h2>Locking</h2>
+ *
+ * <p>Registering the named connections of a namespace runs VFS plugin code,
which reads metadata,
+ * which resolves files through {@link HopVfs} - whose monitor another
execution may be holding
+ * while it looks its own namespace up here. So nothing in this class holds a
lock while calling
+ * out: the registry is only ever locked for a lookup or an update of the map,
never around plugin
+ * code or around closing a manager. A namespace created by one thread and
wanted by another is
+ * waited for through {@link HopVfsNamespace#awaitReady()}, again without a
lock. A Hop Server
+ * running two exports at once deadlocked before this was the rule.
*/
public class HopVfsNamespaces {
@@ -49,6 +61,13 @@ public class HopVfsNamespaces {
private static final Map<IHopMetadataProvider, HopVfsNamespace> NAMESPACES =
new IdentityHashMap<>();
+ /**
+ * Guards {@link #NAMESPACES} and the use counts. Held for map operations
only: whoever holds the
+ * {@link HopVfs} monitor may be waiting on it, so no plugin code, no
metadata reads and no
+ * closing of managers ever happen under it.
+ */
+ private static final Object REGISTRY = new Object();
+
/**
* Where the current namespace lives, for the call sites that resolve a file
without any variables
* in hand. Per thread unless something replaces it: {@code Pipeline} and
{@code Workflow} spawn
@@ -59,7 +78,7 @@ public class HopVfsNamespaces {
* <p>This is the fallback. Where an {@link IVariables} is available,
resolving through it is
* exact and does not depend on where the work ended up running.
*/
- private static IHopScope<HopVfsNamespace> scope =
IHopScope.inheritedByThreads();
+ private static volatile IHopScope<HopVfsNamespace> scope =
IHopScope.inheritedByThreads();
/**
* Change how far "current" reaches. Call this once at startup, before
anything resolves a file:
@@ -69,7 +88,7 @@ public class HopVfsNamespaces {
*
* @param newScope the scope to hold the current namespace in, null restores
the default
*/
- public static synchronized void setScope(IHopScope<HopVfsNamespace>
newScope) {
+ public static void setScope(IHopScope<HopVfsNamespace> newScope) {
scope = newScope == null ? IHopScope.inheritedByThreads() : newScope;
// A runtime only installs a scope of its own when several tenants share
the JVM. There, no
// tenant may fall back to the process wide manager: they would share it,
and one of them
@@ -78,7 +97,7 @@ public class HopVfsNamespaces {
}
/** Set when the runtime serves several tenants at once, so nobody shares
the process manager. */
- private static boolean isolateEverything;
+ private static volatile boolean isolateEverything;
/**
* Does this JVM serve several tenants at once - Hop Web, with a session
scope installed?
@@ -89,7 +108,7 @@ public class HopVfsNamespaces {
*
* @return true when a runtime installed a scope of its own with {@link
#setScope(IHopScope)}
*/
- public static synchronized boolean isIsolated() {
+ public static boolean isIsolated() {
return isolateEverything;
}
@@ -147,7 +166,7 @@ public class HopVfsNamespaces {
* @param description what this namespace belongs to, for logging
* @return the namespace, or null when the process wide manager applies
*/
- public static synchronized HopVfsNamespace acquire(
+ public static HopVfsNamespace acquire(
IVariables variables, IHopMetadataProvider metadataProvider, String
description) {
if (metadataProvider == null || isProcessMetadata(metadataProvider)) {
LogChannel.GENERAL.logDebug(
@@ -157,29 +176,64 @@ public class HopVfsNamespaces {
return null;
}
- HopVfsNamespace namespace = NAMESPACES.get(metadataProvider);
- if (namespace == null) {
+ // Publish-or-retry: the namespace is built outside the lock, so whoever
publishes first wins
+ // and a loser closes what it built. Nothing is ever put in the map or
counted that is not
+ // fully constructed, and a namespace released and removed between two
looks is simply not
+ // found the second time.
+ HopVfsNamespace namespace = null;
+ HopVfsNamespace fresh = null;
+ boolean creator = false;
+ while (namespace == null) {
+ synchronized (REGISTRY) {
+ namespace = NAMESPACES.get(metadataProvider);
+ if (namespace == null && fresh != null) {
+ // Publish before registering: registering the named connections
reads metadata, which
+ // resolves files, which lands right back in resolve() on this very
thread.
+ namespace = fresh;
+ fresh = null;
+ NAMESPACES.put(metadataProvider, namespace);
+ creator = true;
+ }
+ if (namespace != null) {
+ namespace.retain();
+ }
+ }
+ if (namespace == null) {
+ try {
+ fresh = new HopVfsNamespace(description);
+ } catch (HopException e) {
+ LogChannel.GENERAL.logError(
+ "Unable to create a VFS namespace for "
+ + description
+ + ", falling back to the process wide file system manager",
+ e);
+ return null;
+ }
+ }
+ }
+ if (fresh != null) {
+ // Somebody published one for this metadata in the meantime; theirs is
the namespace.
+ fresh.close();
+ }
+
+ if (creator) {
+ // Outside the lock: this runs plugin code that reads metadata through
HopVfs, and another
+ // execution may be holding the HopVfs monitor while it waits for a
lookup here.
try {
- namespace = new HopVfsNamespace(description);
- } catch (HopException e) {
- LogChannel.GENERAL.logError(
- "Unable to create a VFS namespace for "
+ namespace.registerNamedProviders(variables, metadataProvider);
+ LogChannel.GENERAL.logBasic(
+ "Created a VFS namespace for "
+ description
- + ", falling back to the process wide file system manager",
- e);
- return null;
+ + ", holding the named VFS connections of "
+ + metadataProvider.getDescription());
+ } finally {
+ namespace.markReady();
}
- // Publish before registering: registering the named connections reads
metadata, which
- // resolves files, which lands right back in resolve().
- NAMESPACES.put(metadataProvider, namespace);
- namespace.registerNamedProviders(variables, metadataProvider);
- LogChannel.GENERAL.logBasic(
- "Created a VFS namespace for "
- + description
- + ", holding the named VFS connections of "
- + metadataProvider.getDescription());
+ } else {
+ // Somebody else is still registering its connections; a file resolved
before they are in
+ // would silently miss a named connection.
+ namespace.awaitReady();
}
- namespace.retain();
return namespace;
}
@@ -190,17 +244,24 @@ public class HopVfsNamespaces {
*
* @param metadataProvider the metadata the namespace was taken for
*/
- public static synchronized void release(IHopMetadataProvider
metadataProvider) {
+ public static void release(IHopMetadataProvider metadataProvider) {
if (metadataProvider == null) {
return;
}
- HopVfsNamespace namespace = NAMESPACES.get(metadataProvider);
- if (namespace == null) {
- return;
+ HopVfsNamespace lastUse = null;
+ synchronized (REGISTRY) {
+ HopVfsNamespace namespace = NAMESPACES.get(metadataProvider);
+ if (namespace == null) {
+ return;
+ }
+ if (namespace.release() <= 0) {
+ NAMESPACES.remove(metadataProvider);
+ lastUse = namespace;
+ }
}
- if (namespace.release() <= 0) {
- NAMESPACES.remove(metadataProvider);
- namespace.close();
+ if (lastUse != null) {
+ // Closing a manager closes its providers - plugin code again, so not
under the lock.
+ lastUse.close();
}
}
@@ -273,17 +334,20 @@ public class HopVfsNamespaces {
* @return true when a namespace was rebuilt
* @throws HopException if the namespace cannot be rebuilt
*/
- public static synchronized boolean refresh(IVariables variables) throws
HopException {
+ public static boolean refresh(IVariables variables) throws HopException {
HopVfsNamespace namespace = resolve(variables);
if (namespace == null) {
return false;
}
+ // Registers the connections again, so plugin code: the namespace
serializes its own rebuilds.
namespace.rebuild();
return true;
}
- private static synchronized HopVfsNamespace existing(IHopMetadataProvider
provider) {
- return NAMESPACES.get(provider);
+ private static HopVfsNamespace existing(IHopMetadataProvider provider) {
+ synchronized (REGISTRY) {
+ return NAMESPACES.get(provider);
+ }
}
/**
@@ -292,14 +356,20 @@ public class HopVfsNamespaces {
* <p>Leaves the installed scope in place: which runtime this is does not
change because the
* namespaces were closed. A test that installed one puts it back with
{@code setScope(null)}.
*/
- public static synchronized void reset() {
- NAMESPACES.values().forEach(HopVfsNamespace::close);
- NAMESPACES.clear();
+ public static void reset() {
+ List<HopVfsNamespace> open;
+ synchronized (REGISTRY) {
+ open = new ArrayList<>(NAMESPACES.values());
+ NAMESPACES.clear();
+ }
+ open.forEach(HopVfsNamespace::close);
scope.remove();
}
/** How many namespaces are open right now. For tests and diagnostics. */
- public static synchronized int size() {
- return NAMESPACES.size();
+ public static int size() {
+ synchronized (REGISTRY) {
+ return NAMESPACES.size();
+ }
}
}
diff --git
a/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceLockingTest.java
b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceLockingTest.java
new file mode 100644
index 0000000000..5a6d537d00
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsNamespaceLockingTest.java
@@ -0,0 +1,401 @@
+/*
+ * 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.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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.commons.vfs2.impl.DefaultFileSystemManager;
+import org.apache.commons.vfs2.provider.FileProvider;
+import org.apache.commons.vfs2.provider.ram.RamFileProvider;
+import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.extension.IPluginMock;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.plugin.IVfs;
+import org.apache.hop.core.vfs.plugin.VfsPluginType;
+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;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Registering the named connections of a namespace runs plugin code that
reads metadata, and
+ * reading metadata resolves files through {@link HopVfs}. A Hop Server
running two exported
+ * executions at once deadlocked on exactly that: one thread held the {@link
HopVfs} monitor while
+ * looking its namespace up, the other held the {@link HopVfsNamespaces}
monitor while registering
+ * providers and wanted {@link HopVfs}. Neither of the two properties below
may be given up.
+ */
+class HopVfsNamespaceLockingTest {
+
+ private IPlugin registeredPlugin;
+
+ /** What the test VFS plugin does while its providers are being registered.
*/
+ private final AtomicReference<Runnable> whileRegistering = new
AtomicReference<>(() -> {});
+
+ /** The scheme the test VFS plugin registers for an export's metadata;
"locktest" otherwise. */
+ private final Map<IHopMetadataProvider, String> schemeFor = new
ConcurrentHashMap<>();
+
+ @BeforeAll
+ static void initHop() throws Exception {
+ HopClientEnvironment.init();
+ }
+
+ @BeforeEach
+ void setUp() {
+ HopMetadataInstance.setMetadataProvider(null);
+ HopVfs.setBootstrapVariables(null);
+ HopVfs.reset();
+ registerTestVfsPlugin();
+ // The process wide manager belongs to some other metadata; everything
below needs a
+ // namespace of its own, the way an export on a server does.
+ HopMetadataInstance.setMetadataProvider(mock(MultiMetadataProvider.class));
+ HopVfs.setBootstrapVariables(Variables.getADefaultVariableSpace());
+ HopVfs.getFileSystemManager();
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (registeredPlugin != null) {
+ PluginRegistry.getInstance().removePlugin(VfsPluginType.class,
registeredPlugin);
+ registeredPlugin = null;
+ }
+ HopMetadataInstance.setMetadataProvider(null);
+ HopVfs.setBootstrapVariables(null);
+ HopVfs.reset();
+ }
+
+ @Test
+ @DisplayName("Named connections are registered without holding the
namespaces monitor")
+ void registrationDoesNotHoldTheNamespacesMonitor() {
+ // The Azure plugin, for one, reads its connections with a serializer that
validates its
+ // folder through HopVfs.getFileObject(): plugin code under a global
monitor.
+ List<Boolean> heldWhileRegistering = new CopyOnWriteArrayList<>();
+ whileRegistering.set(() ->
heldWhileRegistering.add(Thread.holdsLock(HopVfsNamespaces.class)));
+
+ IHopMetadataProvider exported = mock(MultiMetadataProvider.class);
+ HopVfsNamespace namespace =
+ HopVfsNamespaces.acquire(variablesOf(exported), exported, "exported
workflow");
+ try {
+ assertNotNull(namespace);
+ assertFalse(
+ heldWhileRegistering.isEmpty(), "the test VFS plugin was not asked
for its providers");
+ assertFalse(
+ heldWhileRegistering.contains(Boolean.TRUE),
+ "HopVfsNamespaces holds its monitor while running VFS plugin code,
which resolves"
+ + " files through HopVfs and so waits for whoever holds the
HopVfs monitor");
+ } finally {
+ HopVfsNamespaces.release(exported);
+ }
+ }
+
+ @Test
+ @DisplayName("Resolving through HopVfs never waits for a namespace being
registered elsewhere")
+ void resolvingDoesNotWaitForAnotherNamespacesRegistration() throws Exception
{
+ // A workflow already running on the server, with its namespace in place.
+ IHopMetadataProvider running = mock(MultiMetadataProvider.class);
+ IVariables runningVariables = variablesOf(running);
+ HopVfsNamespace runningNamespace =
+ HopVfsNamespaces.acquire(runningVariables, running, "running
workflow");
+ assertNotNull(runningNamespace);
+
+ // A second export arrives: its namespace is being registered, and the
plugin is busy reading
+ // metadata - long enough for the first workflow to go and load its
pipeline meanwhile.
+ CountDownLatch registering = new CountDownLatch(1);
+ CountDownLatch letRegistrationFinish = new CountDownLatch(1);
+ whileRegistering.set(
+ () -> {
+ registering.countDown();
+ await(letRegistrationFinish);
+ });
+ IHopMetadataProvider arriving = mock(MultiMetadataProvider.class);
+ Thread secondExport =
+ new Thread(
+ () -> HopVfsNamespaces.acquire(variablesOf(arriving), arriving,
"arriving workflow"),
+ "second export");
+ secondExport.setDaemon(true);
+
+ AtomicReference<DefaultFileSystemManager> resolved = new
AtomicReference<>();
+ Thread loadingItsPipeline =
+ new Thread(
+ () -> resolved.set(HopVfs.getFileSystemManager(runningVariables)),
+ "running workflow loading its pipeline");
+ loadingItsPipeline.setDaemon(true);
+
+ try {
+ secondExport.start();
+ assertTrue(registering.await(10, TimeUnit.SECONDS), "registration never
started");
+
+ loadingItsPipeline.start();
+ loadingItsPipeline.join(TimeUnit.SECONDS.toMillis(5));
+
+ assertFalse(
+ loadingItsPipeline.isAlive(),
+ "HopVfs.getFileSystemManager(variables) is stuck behind the
registration of an"
+ + " unrelated namespace: it takes the HopVfs monitor and then
waits for the"
+ + " HopVfsNamespaces monitor. Had that registration needed
HopVfs, as reading"
+ + " metadata does, both would wait forever.");
+ assertSame(runningNamespace.getFileSystemManager(), resolved.get());
+ } finally {
+ letRegistrationFinish.countDown();
+ secondExport.join(TimeUnit.SECONDS.toMillis(10));
+ loadingItsPipeline.join(TimeUnit.SECONDS.toMillis(10));
+ HopVfsNamespaces.release(arriving);
+ HopVfsNamespaces.release(running);
+ }
+ }
+
+ @Test
+ @DisplayName(
+ "A second user of the same metadata waits for the connections, the
registering"
+ + " thread itself does not")
+ void secondAcquirerWaitsForRegistration() throws Exception {
+ IHopMetadataProvider shared = mock(MultiMetadataProvider.class);
+ IVariables variables = variablesOf(shared);
+ CountDownLatch registering = new CountDownLatch(1);
+ CountDownLatch letRegistrationFinish = new CountDownLatch(1);
+ AtomicReference<HopVfsNamespace> seenWhileRegistering = new
AtomicReference<>();
+ whileRegistering.set(
+ () -> {
+ // Reading metadata resolves files: on this thread that must find
the namespace at
+ // once, connections or not, or registration would wait for itself.
+ seenWhileRegistering.set(HopVfsNamespaces.resolve(variables));
+ registering.countDown();
+ await(letRegistrationFinish);
+ });
+
+ AtomicReference<HopVfsNamespace> first = new AtomicReference<>();
+ Thread workflow =
+ new Thread(
+ () -> first.set(HopVfsNamespaces.acquire(variables, shared,
"workflow")), "workflow");
+ workflow.setDaemon(true);
+ AtomicReference<HopVfsNamespace> second = new AtomicReference<>();
+ Thread itsPipeline =
+ new Thread(
+ () -> second.set(HopVfsNamespaces.acquire(variables, shared, "its
pipeline")),
+ "its pipeline");
+ itsPipeline.setDaemon(true);
+
+ try {
+ workflow.start();
+ assertTrue(registering.await(10, TimeUnit.SECONDS), "registration never
started");
+ itsPipeline.start();
+ itsPipeline.join(500);
+ assertTrue(
+ itsPipeline.isAlive(),
+ "The pipeline got the namespace before its named connections were
registered");
+ assertNotNull(seenWhileRegistering.get(), "re-entrant resolution did not
find the namespace");
+ } finally {
+ letRegistrationFinish.countDown();
+ workflow.join(TimeUnit.SECONDS.toMillis(10));
+ itsPipeline.join(TimeUnit.SECONDS.toMillis(10));
+ }
+ try {
+ assertFalse(workflow.isAlive() || itsPipeline.isAlive());
+ assertNotNull(first.get());
+ assertSame(first.get(), second.get());
+ assertSame(first.get(), seenWhileRegistering.get());
+ assertTrue(
+ first.get().getFileSystemManager().hasProvider("locktest"),
+ "the named connection is not registered");
+ } finally {
+ HopVfsNamespaces.release(shared);
+ HopVfsNamespaces.release(shared);
+ }
+ }
+
+ /**
+ * The whole point of a namespace per export (#8106): its named connections
reach nobody else, and
+ * they go away with it. That has to survive the registration now running
outside the lock.
+ */
+ @Test
+ @DisplayName("Concurrent exports keep their connections apart and close with
their last user")
+ void concurrentExportsKeepTheirProvidersApartAndCloseWithTheLastUser()
throws Exception {
+ int exports = 8;
+ int rounds = 25;
+ List<IHopMetadataProvider> providers = new ArrayList<>();
+ for (int i = 0; i < exports; i++) {
+ IHopMetadataProvider export = mock(MultiMetadataProvider.class);
+ schemeFor.put(export, "export" + i);
+ providers.add(export);
+ }
+ DefaultFileSystemManager processWide = HopVfs.getFileSystemManager();
+ Set<DefaultFileSystemManager> managers = ConcurrentHashMap.newKeySet();
+ List<Throwable> failures = new CopyOnWriteArrayList<>();
+ CountDownLatch go = new CountDownLatch(1);
+ List<Thread> threads = new ArrayList<>();
+
+ for (int i = 0; i < exports; i++) {
+ IHopMetadataProvider export = providers.get(i);
+ String mine = schemeFor.get(export);
+ IVariables variables = variablesOf(export);
+ Runnable oneExecution =
+ () -> {
+ // A workflow and, inside it, a pipeline: two users of the same
namespace.
+ HopVfsNamespace workflow = HopVfsNamespaces.acquire(variables,
export, mine);
+ try {
+ HopVfsNamespace pipeline = HopVfsNamespaces.acquire(variables,
export, mine);
+ try {
+ assertSame(workflow, pipeline);
+ DefaultFileSystemManager manager =
HopVfs.getFileSystemManager(variables);
+ managers.add(manager);
+ assertSame(workflow.getFileSystemManager(), manager);
+ assertTrue(manager.hasProvider(mine), mine + " is missing from
its own namespace");
+ for (String other : schemeFor.values()) {
+ if (!other.equals(mine)) {
+ assertFalse(manager.hasProvider(other), other + " leaked
into " + mine);
+ }
+ }
+ assertFalse(
+ processWide.hasProvider(mine), mine + " leaked into the
process manager");
+ } finally {
+ HopVfsNamespaces.release(export);
+ }
+ } finally {
+ HopVfsNamespaces.release(export);
+ }
+ };
+ threads.add(
+ new Thread(
+ () -> {
+ await(go);
+ for (int round = 0; round < rounds; round++) {
+ try {
+ oneExecution.run();
+ } catch (Throwable t) {
+ failures.add(t);
+ return;
+ }
+ }
+ },
+ "export " + i));
+ }
+ // And bystanders that only resolve, the way a running execution loads its
files.
+ for (int i = 0; i < exports; i++) {
+ IVariables variables = variablesOf(providers.get(i));
+ threads.add(
+ new Thread(
+ () -> {
+ await(go);
+ for (int round = 0; round < rounds * 4; round++) {
+ HopVfs.getFileSystemManager(variables);
+ }
+ },
+ "bystander " + i));
+ }
+ threads.forEach(Thread::start);
+ go.countDown();
+ for (Thread thread : threads) {
+ thread.join(TimeUnit.SECONDS.toMillis(60));
+ assertFalse(thread.isAlive(), thread.getName() + " did not finish");
+ }
+
+ assertTrue(failures.isEmpty(), () -> "failures: " + failures);
+ assertTrue(managers.size() >= exports, "each export must have had a
manager of its own");
+ assertSame(0, HopVfsNamespaces.size(), "namespaces left open after their
last user let go");
+ for (DefaultFileSystemManager manager : managers) {
+ // A closed manager has dropped every provider, the local one included.
+ assertFalse(manager.hasProvider("file"), "a namespace's manager was
never closed");
+ }
+ for (String scheme : schemeFor.values()) {
+ assertFalse(processWide.hasProvider(scheme), scheme + " leaked into the
process manager");
+ }
+ assertTrue(processWide.hasProvider("file"), "the process manager itself
must stay open");
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ if (!latch.await(10, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("Timed out waiting for the test to
move on");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /** The variables of something running against this metadata, the way an
execution's are. */
+ private static Variables variablesOf(IHopMetadataProvider provider) {
+ return new Variables() {
+ @Override
+ public IHopMetadataProvider getMetadataProvider() {
+ return provider;
+ }
+ };
+ }
+
+ /** A VFS plugin that, like the metadata driven ones, does work of its own
while registering. */
+ private void registerTestVfsPlugin() {
+ IVfs vfs =
+ new IVfs() {
+ @Override
+ public String[] getUrlSchemes() {
+ return new String[] {};
+ }
+
+ @Override
+ public FileProvider getProvider() {
+ return new RamFileProvider();
+ }
+
+ @Override
+ public Map<String, FileProvider> getProviders(IVariables variables) {
+ whileRegistering.get().run();
+ Map<String, FileProvider> providers = new HashMap<>();
+ // Each export carries a connection of its own, named after the
export.
+ IHopMetadataProvider owner =
+ variables == null ? null :
variables.findExecutionMetadataProvider();
+ String scheme = owner == null ? null : schemeFor.get(owner);
+ providers.put(scheme == null ? "locktest" : scheme, new
RamFileProvider());
+ return providers;
+ }
+ };
+
+ IPluginMock plugin = mock(IPluginMock.class);
+ when(plugin.getIds()).thenReturn(new String[] {"test-vfs-locking-plugin"});
+ when(plugin.getName()).thenReturn("Test VFS locking plugin");
+ when(plugin.getMainType()).thenReturn((Class) IVfs.class);
+ when(plugin.loadClass(IVfs.class)).thenReturn(vfs);
+ try {
+ PluginRegistry.getInstance().registerPlugin(VfsPluginType.class, plugin);
+ } catch (Exception e) {
+ throw new IllegalStateException("Unable to register the test VFS
plugin", e);
+ }
+ registeredPlugin = plugin;
+ }
+}
diff --git a/integration-tests/hop_server/0019-child-delay.hpl
b/integration-tests/hop_server/0019-child-delay.hpl
new file mode 100644
index 0000000000..d8894a3a31
--- /dev/null
+++ b/integration-tests/hop_server/0019-child-delay.hpl
@@ -0,0 +1,108 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <capture_transform_performance>N</capture_transform_performance>
+
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>0</pipeline_status>
+ <parameters/>
+ <name>0019-child-delay</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Small worker pipeline that occupies a server slot for 2
seconds</description>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/08/18 12:53:30.940</created_date>
+ <modified_date>2026/08/18 12:53:30.940</modified_date>
+ </info>
+ <transform>
+ <type>RowGenerator</type>
+ <name>1 row</name>
+ <never_ending>N</never_ending>
+ <interval_in_ms>5000</interval_in_ms>
+ <row_time_field/>
+ <last_time_field/>
+ <limit>1</limit>
+ <fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ <description/>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>Delay</type>
+ <name>Delay 1s</name>
+ <timeout>1</timeout>
+ <scaletime>seconds</scaletime>
+ <scaletime_from_field>N</scaletime_from_field>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>272</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ <description/>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>Dummy</type>
+ <name>Dummy</name>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>448</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ <description/>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <order>
+ <hop>
+ <from>1 row</from>
+ <to>Delay 1s</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Delay 1s</from>
+ <to>Dummy</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <notepads/>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/integration-tests/hop_server/0019-child-delay.hwf
b/integration-tests/hop_server/0019-child-delay.hwf
new file mode 100644
index 0000000000..e2d90998d2
--- /dev/null
+++ b/integration-tests/hop_server/0019-child-delay.hwf
@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+ <name>0019-child-delay</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Small worker workflow that occupies a server slot for 2
seconds</description>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <hour>12</hour>
+ <minutes>0</minutes>
+ <weekDay>1</weekDay>
+ <DayOfMonth>1</DayOfMonth>
+ <parallel>N</parallel>
+ <xloc>64</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>0019-child-delay.hpl</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <filename>${PROJECT_HOME}/0019-child-delay.hpl</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <set_logfile>N</set_logfile>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <wait_until_finished>Y</wait_until_finished>
+ <create_parent_folder>N</create_parent_folder>
+ <run_configuration>local</run_configuration>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <set_append_logfile>N</set_append_logfile>
+ <parallel>N</parallel>
+ <xloc>240</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>Success</name>
+ <description/>
+ <type>SUCCESS</type>
+ <attributes/>
+ <parallel>N</parallel>
+ <xloc>448</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>0019-child-delay.hpl</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ <hop>
+ <from>0019-child-delay.hpl</from>
+ <to>Success</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git a/integration-tests/hop_server/0019-drive-200-workflows.hpl
b/integration-tests/hop_server/0019-drive-200-workflows.hpl
new file mode 100644
index 0000000000..103415bb97
--- /dev/null
+++ b/integration-tests/hop_server/0019-drive-200-workflows.hpl
@@ -0,0 +1,181 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+ <info>
+ <name>0019-drive-200-workflows</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Submit 200 child workflows as exports to the one Hop Server,
eight at a time</description>
+ <pipeline_type>Normal</pipeline_type>
+ <parameters>
+ </parameters>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>200 rows</from>
+ <to>Workflow executor</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Workflow executor</from>
+ <to>Count successes</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Count successes</from>
+ <to>Set EXEC_OK</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>200 rows</name>
+ <type>RowGenerator</type>
+ <description/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ </fields>
+ <limit>200</limit>
+ <never_ending>N</never_ending>
+ <interval_in_ms>5000</interval_in_ms>
+ <row_time_field/>
+ <last_time_field/>
+ <attributes/>
+ <GUI>
+ <xloc>80</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Workflow executor</name>
+ <type>WorkflowExecutor</type>
+ <description/>
+ <distribute>Y</distribute>
+ <copies>8</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <run_configuration>remote</run_configuration>
+ <filename>${PROJECT_HOME}/0019-child-delay.hwf</filename>
+ <group_size>1</group_size>
+ <group_field/>
+ <group_time/>
+ <parameters>
+ <inherit_all_vars>Y</inherit_all_vars>
+ </parameters>
+ <execution_result_target_transform>Count
successes</execution_result_target_transform>
+ <execution_time_field>ExecutionTime</execution_time_field>
+ <execution_result_field>ExecutionResult</execution_result_field>
+ <execution_errors_field>ExecutionNrErrors</execution_errors_field>
+ <execution_lines_read_field/>
+ <execution_lines_written_field/>
+ <execution_lines_input_field/>
+ <execution_lines_output_field/>
+ <execution_lines_rejected_field/>
+ <execution_lines_updated_field/>
+ <execution_lines_deleted_field/>
+ <execution_files_retrieved_field/>
+ <execution_exit_status_field/>
+ <execution_log_text_field/>
+ <execution_log_channelid_field/>
+ <result_rows_target_transform/>
+ <result_files_target_transform/>
+ <attributes/>
+ <GUI>
+ <xloc>288</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Count successes</name>
+ <type>GroupBy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <add_linenr>N</add_linenr>
+ <all_rows>N</all_rows>
+ <directory>${java.io.tmpdir}</directory>
+ <fields>
+ <field>
+ <aggregate>okCount</aggregate>
+ <type>COUNT_ANY</type>
+ </field>
+ <field>
+ <aggregate>errorSum</aggregate>
+ <subject>ExecutionNrErrors</subject>
+ <type>SUM</type>
+ </field>
+ </fields>
+ <give_back_row>N</give_back_row>
+ <group>
+ </group>
+ <ignore_aggregate>N</ignore_aggregate>
+ <prefix>grp</prefix>
+ <attributes/>
+ <GUI>
+ <xloc>512</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Set EXEC_OK</name>
+ <type>SetVariable</type>
+ <description/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <fields>
+ <field>
+ <field_name>okCount</field_name>
+ <variable_name>EXEC_OK</variable_name>
+ <variable_type>PARENT_WORKFLOW</variable_type>
+ <default_value>0</default_value>
+ </field>
+ <field>
+ <field_name>errorSum</field_name>
+ <variable_name>EXEC_ERRORS</variable_name>
+ <variable_type>PARENT_WORKFLOW</variable_type>
+ <default_value>0</default_value>
+ </field>
+ </fields>
+ <use_formatting>Y</use_formatting>
+ <attributes/>
+ <GUI>
+ <xloc>704</xloc>
+ <yloc>80</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/integration-tests/hop_server/main-0019-concurrent-exports-single-server.hwf
b/integration-tests/hop_server/main-0019-concurrent-exports-single-server.hwf
new file mode 100644
index 0000000000..bbc8e3a069
--- /dev/null
+++
b/integration-tests/hop_server/main-0019-concurrent-exports-single-server.hwf
@@ -0,0 +1,168 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+ <name>main-0019-concurrent-exports-single-server</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Fire 200 exported child workflows at the one Hop Server, eight
at a time. Two exports starting together used to deadlock the server between
HopVfs and HopVfsNamespaces; every one of them must finish.</description>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/09/18 17:30:00.000</created_date>
+ <modified_date>2026/09/18 17:30:00.000</modified_date>
+ <parameters/>
+ <actions>
+ <action>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <DayOfMonth>1</DayOfMonth>
+ <weekDay>1</weekDay>
+ <minutes>0</minutes>
+ <hour>12</hour>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <xloc>48</xloc>
+ <yloc>160</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <replacevars>Y</replacevars>
+ <fields>
+ <field>
+ <variable_name>EXEC_OK</variable_name>
+ <variable_value>0</variable_value>
+ <variable_type>CURRENT_WORKFLOW</variable_type>
+ </field>
+ <field>
+ <variable_name>EXEC_ERRORS</variable_name>
+ <variable_value>0</variable_value>
+ <variable_type>CURRENT_WORKFLOW</variable_type>
+ </field>
+ </fields>
+ <file_variable_type>CURRENT_WORKFLOW</file_variable_type>
+ <name>Init variables</name>
+ <description/>
+ <type>SET_VARIABLES</type>
+ <attributes/>
+ <xloc>192</xloc>
+ <yloc>160</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <filename>${PROJECT_HOME}/0019-drive-200-workflows.hpl</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <clear_rows>N</clear_rows>
+ <clear_files>N</clear_files>
+ <create_parent_folder>N</create_parent_folder>
+ <set_logfile>N</set_logfile>
+ <set_append_logfile>N</set_append_logfile>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <wait_until_finished>Y</wait_until_finished>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <run_configuration>local</run_configuration>
+ <name>Drive 200 workflows</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <xloc>384</xloc>
+ <yloc>160</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <valuetype>variable</valuetype>
+ <fieldtype>number</fieldtype>
+ <variablename>EXEC_OK</variablename>
+ <comparevalue>200</comparevalue>
+ <successcondition>equal</successcondition>
+ <successwhenvarset>N</successwhenvarset>
+ <successbooleancondition>true</successbooleancondition>
+ <successnumbercondition>equal</successnumbercondition>
+ <name>200 executions</name>
+ <description/>
+ <type>SIMPLE_EVAL</type>
+ <attributes/>
+ <xloc>576</xloc>
+ <yloc>272</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <valuetype>variable</valuetype>
+ <fieldtype>number</fieldtype>
+ <variablename>EXEC_ERRORS</variablename>
+ <comparevalue>0</comparevalue>
+ <successcondition>equal</successcondition>
+ <successwhenvarset>N</successwhenvarset>
+ <successbooleancondition>true</successbooleancondition>
+ <successnumbercondition>equal</successnumbercondition>
+ <name>No execution errors</name>
+ <description/>
+ <type>SIMPLE_EVAL</type>
+ <attributes/>
+ <xloc>768</xloc>
+ <yloc>272</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>Init variables</to>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Init variables</from>
+ <to>Drive 200 workflows</to>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Drive 200 workflows</from>
+ <to>200 executions</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>200 executions</from>
+ <to>No execution errors</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ </hops>
+ <notepads/>
+ <attributes/>
+</workflow>