allPlugins = new ArrayList<>(plugins.values());
+ if (pluginManager != null)
+ allPlugins.addAll(pluginManager.getPlugins());
+ return Collections.unmodifiableCollection(allPlugins);
}
public ServerDatabase getDatabase(final String databaseName) {
@@ -502,7 +532,7 @@ public String toString() {
}
public ServerDatabase getDatabase(final String databaseName, final boolean createIfNotExists,
- final boolean allowLoad) {
+ final boolean allowLoad) {
if (databaseName == null || databaseName.trim().isEmpty())
throw new IllegalArgumentException("Invalid database name " + databaseName);
@@ -523,7 +553,7 @@ public ServerDatabase getDatabase(final String databaseName, final boolean creat
ComponentFile.MODE defaultDbMode =
configuration.getValueAsEnum(GlobalConfiguration.SERVER_DEFAULT_DATABASE_MODE,
- ComponentFile.MODE.class);
+ ComponentFile.MODE.class);
if (defaultDbMode == null)
defaultDbMode = READ_WRITE;
@@ -620,46 +650,46 @@ private void loadDefaultDatabases() {
final String commandParams = command.substring(commandSeparator + 1);
switch (commandType) {
- case "restore":
- // DROP THE DATABASE BECAUSE THE RESTORE OPERATION WILL TAKE CARE OF CREATING A NEW DATABASE
- if (database != null) {
- ((DatabaseInternal) database).getEmbedded().drop();
- databases.remove(dbName);
- }
- final String dbPath =
- configuration.getValueAsString(GlobalConfiguration.SERVER_DATABASE_DIRECTORY) + File.separator + dbName;
+ case "restore":
+ // DROP THE DATABASE BECAUSE THE RESTORE OPERATION WILL TAKE CARE OF CREATING A NEW DATABASE
+ if (database != null) {
+ ((DatabaseInternal) database).getEmbedded().drop();
+ databases.remove(dbName);
+ }
+ final String dbPath =
+ configuration.getValueAsString(GlobalConfiguration.SERVER_DATABASE_DIRECTORY) + File.separator + dbName;
// new Restore(commandParams, dbPath).restoreDatabase();
- try {
- final Class> clazz = Class.forName("com.arcadedb.integration.restore.Restore");
- final Object restorer = clazz.getConstructor(String.class, String.class).newInstance(commandParams,
- dbPath);
+ try {
+ final Class> clazz = Class.forName("com.arcadedb.integration.restore.Restore");
+ final Object restorer = clazz.getConstructor(String.class, String.class).newInstance(commandParams,
+ dbPath);
- clazz.getMethod("restoreDatabase").invoke(restorer);
+ clazz.getMethod("restoreDatabase").invoke(restorer);
- } catch (final ClassNotFoundException | NoSuchMethodException | IllegalAccessException |
- InstantiationException e) {
- throw new CommandExecutionException("Error on restoring database, restore libs not found in " +
- "classpath", e);
- } catch (final InvocationTargetException e) {
- throw new CommandExecutionException("Error on restoring database", e.getTargetException());
- }
+ } catch (final ClassNotFoundException | NoSuchMethodException | IllegalAccessException |
+ InstantiationException e) {
+ throw new CommandExecutionException("Error on restoring database, restore libs not found in " +
+ "classpath", e);
+ } catch (final InvocationTargetException e) {
+ throw new CommandExecutionException("Error on restoring database", e.getTargetException());
+ }
- getDatabase(dbName);
- break;
+ getDatabase(dbName);
+ break;
- case "import":
- if (database == null) {
- // CREATE THE DATABASE
- LogManager.instance().log(this, Level.INFO, "Creating default database '%s'...", null, dbName);
- database = createDatabase(dbName, defaultDbMode);
- }
- database.command("sql", "import database " + commandParams);
- break;
+ case "import":
+ if (database == null) {
+ // CREATE THE DATABASE
+ LogManager.instance().log(this, Level.INFO, "Creating default database '%s'...", null, dbName);
+ database = createDatabase(dbName, defaultDbMode);
+ }
+ database.command("sql", "import database " + commandParams);
+ break;
- default:
- LogManager.instance().log(this, Level.SEVERE, "Unsupported command %s in startup command: '%s'", null
- , commandType);
+ default:
+ LogManager.instance().log(this, Level.SEVERE, "Unsupported command %s in startup command: '%s'", null
+ , commandType);
}
}
} else {
@@ -700,7 +730,7 @@ private void parseCredentials(final String dbName, final String credentials) {
user = security.authenticate(userName, userPassword, dbName);
// UPDATE DB LIST + GROUP
- user.addDatabase(dbName, new String[]{userGroup});
+ user.addDatabase(dbName, new String[] { userGroup });
security.saveUsers();
} catch (final ServerSecurityException e) {
@@ -711,7 +741,7 @@ private void parseCredentials(final String dbName, final String credentials) {
}
} else {
// UPDATE DB LIST
- user.addDatabase(dbName, new String[]{userGroup});
+ user.addDatabase(dbName, new String[] { userGroup });
security.saveUsers();
}
} else {
@@ -722,7 +752,7 @@ private void parseCredentials(final String dbName, final String credentials) {
// UPDATE DB LIST + GROUP
ServerSecurityUser user = security.getUser(userName);
- user.addDatabase(dbName, new String[]{userGroup});
+ user.addDatabase(dbName, new String[] { userGroup });
security.saveUsers();
}
}
@@ -745,6 +775,7 @@ private void loadConfiguration() {
private void init() {
eventLog = new FileServerEventLog(this);
+ pluginManager = new PluginManager(this, configuration);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
// Mark logger as shutting down to prevent NPE when handlers are closed (issue #2813)
diff --git a/server/src/main/java/com/arcadedb/server/ServerPlugin.java b/server/src/main/java/com/arcadedb/server/ServerPlugin.java
index 746993ed7a..9e31a180dc 100644
--- a/server/src/main/java/com/arcadedb/server/ServerPlugin.java
+++ b/server/src/main/java/com/arcadedb/server/ServerPlugin.java
@@ -22,10 +22,14 @@
import com.arcadedb.server.http.HttpServer;
import io.undertow.server.handlers.PathHandler;
-import static com.arcadedb.server.ServerPlugin.INSTALLATION_PRIORITY.BEFORE_HTTP_ON;
+import static com.arcadedb.server.ServerPlugin.PluginInstallationPriority.BEFORE_HTTP_ON;
public interface ServerPlugin {
- enum INSTALLATION_PRIORITY {BEFORE_HTTP_ON, AFTER_HTTP_ON, AFTER_DATABASES_OPEN}
+ enum PluginInstallationPriority {BEFORE_HTTP_ON, AFTER_HTTP_ON, AFTER_DATABASES_OPEN}
+
+ default String getName() {
+ return this.getClass().getSimpleName();
+ }
default void configure(ArcadeDBServer arcadeDBServer, ContextConfiguration configuration) {
// DEFAULT IMPLEMENTATION
@@ -41,7 +45,7 @@ default void registerAPI(final HttpServer httpServer, final PathHandler routes)
// DEFAULT IMPLEMENTATION
}
- default INSTALLATION_PRIORITY getInstallationPriority() {
+ default PluginInstallationPriority getInstallationPriority() {
return BEFORE_HTTP_ON;
}
}
diff --git a/server/src/main/java/com/arcadedb/server/backup/AutoBackupSchedulerPlugin.java b/server/src/main/java/com/arcadedb/server/backup/AutoBackupSchedulerPlugin.java
index 1509a42313..14ff5d58b5 100644
--- a/server/src/main/java/com/arcadedb/server/backup/AutoBackupSchedulerPlugin.java
+++ b/server/src/main/java/com/arcadedb/server/backup/AutoBackupSchedulerPlugin.java
@@ -193,9 +193,9 @@ public void stopService() {
}
@Override
- public INSTALLATION_PRIORITY getInstallationPriority() {
+ public PluginInstallationPriority getInstallationPriority() {
// Install after databases are open so we can schedule backups for all databases
- return INSTALLATION_PRIORITY.AFTER_DATABASES_OPEN;
+ return PluginInstallationPriority.AFTER_DATABASES_OPEN;
}
/**
diff --git a/server/src/main/java/com/arcadedb/server/plugin/PluginClassLoader.java b/server/src/main/java/com/arcadedb/server/plugin/PluginClassLoader.java
new file mode 100644
index 0000000000..f218557469
--- /dev/null
+++ b/server/src/main/java/com/arcadedb/server/plugin/PluginClassLoader.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com)
+ *
+ * Licensed 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.
+ *
+ * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com)
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.arcadedb.server.plugin;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+
+/**
+ * Custom class loader for plugins that provides isolation while allowing access to server APIs.
+ *
+ * This class loader follows a parent-first delegation model for server classes (com.arcadedb.server.*)
+ * and a child-first model for plugin-specific classes, allowing each plugin to have its own
+ * version of dependencies.
+ *
+ * @author Luca Garulli (l.garulli@arcadedata.com)
+ */
+public class PluginClassLoader extends URLClassLoader {
+ private static final String SERVER_PACKAGE_PREFIX = "com.arcadedb.";
+
+ public PluginClassLoader(final String pluginName, final File pluginJarFile, final ClassLoader parent)
+ throws MalformedURLException {
+ super(new URL[]{pluginJarFile.toURI().toURL()}, parent);
+ }
+
+ @Override
+ protected Class> loadClass(final String name, final boolean resolve) throws ClassNotFoundException {
+ // Always delegate server API classes to parent to ensure shared instances
+ if (name.startsWith(SERVER_PACKAGE_PREFIX)) {
+ return super.loadClass(name, resolve);
+ }
+
+ // For plugin classes, try to load from this class loader first
+ synchronized (getClassLoadingLock(name)) {
+ // Check if the class has already been loaded by this class loader
+ Class> c = findLoadedClass(name);
+ if (c == null) {
+ try {
+ // Try to load from this class loader's JAR first
+ c = findClass(name);
+ } catch (final ClassNotFoundException e) {
+ // If not found, delegate to parent
+ c = super.loadClass(name, resolve);
+ }
+ }
+ if (resolve) {
+ resolveClass(c);
+ }
+ return c;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "PluginClassLoader{urls=" + java.util.Arrays.toString(getURLs()) + "}";
+ }
+}
diff --git a/server/src/main/java/com/arcadedb/server/plugin/PluginDescriptor.java b/server/src/main/java/com/arcadedb/server/plugin/PluginDescriptor.java
new file mode 100644
index 0000000000..75fbe747fa
--- /dev/null
+++ b/server/src/main/java/com/arcadedb/server/plugin/PluginDescriptor.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com)
+ *
+ * Licensed 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.
+ *
+ * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com)
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.arcadedb.server.plugin;
+
+import com.arcadedb.server.ServerPlugin;
+
+import java.util.Objects;
+
+/**
+ * Descriptor for a plugin that provides metadata and lifecycle management.
+ * Each plugin is loaded in its own class loader for isolation.
+ *
+ * @author Luca Garulli (l.garulli@arcadedata.com)
+ */
+public class PluginDescriptor {
+ private final String pluginName;
+ private final ClassLoader classLoader;
+ private ServerPlugin pluginInstance;
+ private boolean started;
+
+ public PluginDescriptor(final String pluginName, final ClassLoader classLoader) {
+ this.pluginName = Objects.requireNonNull(pluginName, "Plugin name cannot be null");
+ this.classLoader = Objects.requireNonNull(classLoader, "Class loader cannot be null");
+ this.started = false;
+ }
+
+ public String getPluginName() {
+ return pluginName;
+ }
+
+ public ClassLoader getClassLoader() {
+ return classLoader;
+ }
+
+ public ServerPlugin getPluginInstance() {
+ return pluginInstance;
+ }
+
+ public void setPluginInstance(final ServerPlugin pluginInstance) {
+ this.pluginInstance = pluginInstance;
+ }
+
+ public boolean isStarted() {
+ return started;
+ }
+
+ public void setStarted(final boolean started) {
+ this.started = started;
+ }
+
+ @Override
+ public String toString() {
+ return "PluginDescriptor{" +
+ "pluginName='" + pluginName + '\'' +
+ ", started=" + started +
+ '}';
+ }
+}
diff --git a/server/src/main/java/com/arcadedb/server/plugin/PluginManager.java b/server/src/main/java/com/arcadedb/server/plugin/PluginManager.java
new file mode 100644
index 0000000000..66d0808dfd
--- /dev/null
+++ b/server/src/main/java/com/arcadedb/server/plugin/PluginManager.java
@@ -0,0 +1,298 @@
+/*
+ * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com)
+ *
+ * Licensed 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.
+ *
+ * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com)
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.arcadedb.server.plugin;
+
+import com.arcadedb.ContextConfiguration;
+import com.arcadedb.GlobalConfiguration;
+import com.arcadedb.log.LogManager;
+import com.arcadedb.server.ArcadeDBServer;
+import com.arcadedb.server.ServerException;
+import com.arcadedb.server.ServerPlugin;
+import com.arcadedb.utility.CodeUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Level;
+
+/**
+ * Manager for loading and managing plugins using isolated class loaders.
+ * Plugins are discovered from the lib/plugins directory using the ServiceLoader pattern.
+ *
+ * @author Luca Garulli (l.garulli@arcadedata.com)
+ */
+public class PluginManager {
+ private final ArcadeDBServer server;
+ private final ContextConfiguration configuration;
+ private final String pluginsDirectory;
+ private final Map plugins = new LinkedHashMap<>();
+ private final Map classLoaderMap = new ConcurrentHashMap<>();
+ private final Set configuredPlugins;
+
+ public PluginManager(final ArcadeDBServer server, final ContextConfiguration configuration) {
+ this.server = server;
+ this.configuration = configuration;
+ this.pluginsDirectory = server.getRootPath() + File.separator + "lib" + File.separator + "plugins";
+ configuredPlugins = getConfiguredPlugins();
+
+ }
+
+ private Set getConfiguredPlugins() {
+ final String configuration = this.configuration.getValueAsString(GlobalConfiguration.SERVER_PLUGINS);
+ Set configuredPlugins = new HashSet<>();
+ if (!configuration.isEmpty()) {
+ final String[] pluginEntries = configuration.split(",");
+ for (final String p : pluginEntries) {
+ final String[] pluginPair = p.split(":");
+
+ final String pluginName = pluginPair[0];
+ configuredPlugins.add(pluginName);
+ final String pluginClass = pluginPair.length > 1 ? pluginPair[1] : pluginPair[0];
+ configuredPlugins.add(pluginClass);
+ }
+ }
+ return configuredPlugins;
+ }
+
+ private void discoverPluginsOnMainClassLoader() {
+ final ServiceLoader serviceLoader = ServiceLoader.load(ServerPlugin.class, getClass().getClassLoader());
+
+ for (ServerPlugin pluginInstance : serviceLoader) {
+ String name = pluginInstance.getClass().getSimpleName();
+ if (configuredPlugins.contains(name) || configuredPlugins.contains(pluginInstance.getClass().getName())) {
+ // Register the plugin
+ final PluginDescriptor descriptor = new PluginDescriptor(name, getClass().getClassLoader());
+ descriptor.setPluginInstance(pluginInstance);
+ plugins.put(name, descriptor);
+
+ LogManager.instance().log(this, Level.INFO, "Discovered plugin on main class loader: %s", name);
+ }
+ }
+ }
+
+ /**
+ * Discover and load plugins from the plugins directory.
+ * Each plugin JAR is loaded in its own isolated class loader.
+ */
+ public void discoverPlugins() {
+ discoverPluginsOnMainClassLoader();
+
+ final File pluginsDir = new File(pluginsDirectory);
+ if (!pluginsDir.exists() || !pluginsDir.isDirectory()) {
+ LogManager.instance().log(this, Level.INFO, "Plugins directory not found: %s", pluginsDirectory);
+ return;
+ }
+
+ final File[] pluginJars = pluginsDir.listFiles((dir, name) -> name.endsWith(".jar"));
+ if (pluginJars == null || pluginJars.length == 0) {
+ LogManager.instance().log(this, Level.INFO, "No plugin JARs found in: %s", pluginsDirectory);
+ return;
+ }
+
+ for (final File pluginJar : pluginJars) {
+ try {
+ loadPlugin(pluginJar);
+ } catch (final Exception e) {
+ LogManager.instance().log(this, Level.SEVERE, "Failed to load plugin from: %s", e, pluginJar.getAbsolutePath());
+ }
+ }
+ }
+
+ /**
+ * Load a plugin from a JAR file using an isolated class loader.
+ */
+ private void loadPlugin(final File pluginJar) throws Exception {
+ final String jarName = pluginJar.getName();
+ final String pluginName = jarName.substring(0, jarName.lastIndexOf('.'));
+
+ LogManager.instance().log(this, Level.FINE, "Loading plugin from: %s", pluginJar.getAbsolutePath());
+
+ // Create isolated class loader for this plugin
+ final PluginClassLoader classLoader = new PluginClassLoader(pluginName, pluginJar, getClass().getClassLoader());
+ boolean registered = false;
+
+ try {
+ // Use ServiceLoader to discover plugin implementations
+ final ServiceLoader serviceLoader = ServiceLoader.load(ServerPlugin.class, classLoader);
+
+ // Load the first plugin implementation (typically only one per JAR)
+ for (ServerPlugin pluginInstance : serviceLoader) {
+ // Create plugin descriptor
+ final PluginDescriptor descriptor = new PluginDescriptor(pluginInstance.getName(), classLoader);
+ descriptor.setPluginInstance(pluginInstance);
+
+ String name = pluginInstance.getName();
+ LogManager.instance().log(this, Level.FINE, "Discovered plugin class: %s", name);
+
+ if (plugins.containsKey(name)) {
+ LogManager.instance().log(this, Level.WARNING, "Plugin with name '%s' is already loaded, skipping duplicate from %s",
+ name, pluginJar.getName());
+ break; // Exit loop - classloader will be closed in finally block
+ }
+
+ if (configuredPlugins.contains(name) || configuredPlugins.contains(pluginName) || configuredPlugins.contains(
+ pluginInstance.getClass().getName())) {
+ // Register the plugin
+ plugins.put(name, descriptor);
+ classLoaderMap.put(classLoader, descriptor);
+ registered = true;
+
+ LogManager.instance().log(this, Level.INFO, "Loaded plugin: %s from %s", name, pluginJar.getName());
+ } else {
+ LogManager.instance().log(this, Level.INFO, "Skipping plugin: %s as not registered in configuration", name);
+ }
+ break; // Only load the first plugin from each JAR
+ }
+ } finally {
+ if (!registered) {
+ classLoader.close();
+ }
+ }
+ }
+
+ /**
+ * Start plugins based on their installation priority.
+ */
+ public void startPlugins(final ServerPlugin.PluginInstallationPriority priority) {
+ for (final Map.Entry entry : plugins.entrySet()) {
+ final String pluginName = entry.getKey();
+ final PluginDescriptor descriptor = entry.getValue();
+ final ServerPlugin plugin = descriptor.getPluginInstance();
+
+ if (plugin == null || descriptor.isStarted()) {
+ continue;
+ }
+
+ if (plugin.getInstallationPriority() != priority) {
+ continue;
+ }
+
+ try {
+ // Set the context class loader to the plugin's class loader
+ final Thread currentThread = Thread.currentThread();
+ final ClassLoader originalClassLoader = currentThread.getContextClassLoader();
+ try {
+ currentThread.setContextClassLoader(descriptor.getClassLoader());
+
+ // Configure and start the plugin
+ plugin.configure(server, configuration);
+ plugin.startService();
+
+ descriptor.setStarted(true);
+ LogManager.instance().log(this, Level.INFO, "- %s plugin started", pluginName);
+
+ } finally {
+ currentThread.setContextClassLoader(originalClassLoader);
+ }
+ } catch (final Exception e) {
+ throw new ServerException("Error starting plugin: " + pluginName + " (priority: " + priority + ")", e);
+ }
+ }
+ }
+
+ /**
+ * Stop all plugins in reverse order of registration.
+ */
+ public void stopPlugins() {
+ final List> pluginList = new ArrayList<>(plugins.entrySet());
+ Collections.reverse(pluginList);
+
+ for (final Map.Entry entry : pluginList) {
+ final String pluginName = entry.getKey();
+ final PluginDescriptor descriptor = entry.getValue();
+ final ServerPlugin plugin = descriptor.getPluginInstance();
+
+ if (plugin == null || !descriptor.isStarted()) {
+ continue;
+ }
+
+ LogManager.instance().log(this, Level.INFO, "- Stop %s plugin", pluginName);
+
+ final Thread currentThread = Thread.currentThread();
+ final ClassLoader originalClassLoader = currentThread.getContextClassLoader();
+ try {
+ currentThread.setContextClassLoader(descriptor.getClassLoader());
+ CodeUtils.executeIgnoringExceptions(plugin::stopService,
+ "Error stopping plugin: " + pluginName, false);
+ descriptor.setStarted(false);
+ } finally {
+ currentThread.setContextClassLoader(originalClassLoader);
+ }
+ }
+
+ // Close class loaders
+ for (final PluginDescriptor descriptor : plugins.values()) {
+ final ClassLoader classLoader = descriptor.getClassLoader();
+ if (classLoader instanceof PluginClassLoader) {
+ try {
+ ((PluginClassLoader) classLoader).close();
+ } catch (final IOException e) {
+ LogManager.instance().log(this, Level.WARNING, "Error closing class loader for plugin: %s",
+ e, descriptor.getPluginName());
+ }
+ }
+ }
+
+ plugins.clear();
+ classLoaderMap.clear();
+ }
+
+ /**
+ * Get all loaded plugins.
+ */
+ public Collection getPlugins() {
+ final List result = new ArrayList<>();
+ for (final PluginDescriptor descriptor : plugins.values()) {
+ if (descriptor.getPluginInstance() != null) {
+ result.add(descriptor.getPluginInstance());
+ }
+ }
+ return Collections.unmodifiableCollection(result);
+ }
+
+ /**
+ * Get the number of loaded plugins.
+ */
+ public int getPluginCount() {
+ return plugins.size();
+ }
+
+ /**
+ * Get plugin names.
+ */
+ public Set getPluginNames() {
+ return Collections.unmodifiableSet(plugins.keySet());
+ }
+
+ /**
+ * Get plugin descriptor by name.
+ */
+ public PluginDescriptor getPluginDescriptor(final String pluginName) {
+ return plugins.get(pluginName);
+ }
+}
diff --git a/server/src/main/java/com/arcadedb/server/plugin/package-info.java b/server/src/main/java/com/arcadedb/server/plugin/package-info.java
new file mode 100644
index 0000000000..2611a801ab
--- /dev/null
+++ b/server/src/main/java/com/arcadedb/server/plugin/package-info.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com)
+ *
+ * Licensed 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.
+ *
+ * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com)
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Plugin architecture for ArcadeDB with isolated class loaders.
+ *
+ * This package provides infrastructure for loading and managing plugins in isolated class loaders,
+ * allowing each plugin to have its own set of dependencies without conflicts.
+ *
+ *
Architecture
+ *
+ * The plugin system consists of three main components:
+ *
+ * - {@link com.arcadedb.server.plugin.PluginManager} - Discovers and manages plugin lifecycle
+ * - {@link com.arcadedb.server.plugin.PluginClassLoader} - Provides isolated class loading
+ * - {@link com.arcadedb.server.plugin.PluginDescriptor} - Holds plugin metadata and state
+ *
+ *
+ * Plugin Discovery
+ *
+ * Plugins are discovered using the Java ServiceLoader pattern. Each plugin JAR must:
+ *
+ * - Implement {@link com.arcadedb.server.ServerPlugin} interface
+ * - Provide a META-INF/services/com.arcadedb.server.ServerPlugin file with the implementation class name
+ * - Be placed in the {@code lib/plugins/} directory
+ *
+ *
+ * Class Loading Strategy
+ *
+ * The {@link com.arcadedb.server.plugin.PluginClassLoader} uses a hybrid delegation model:
+ *
+ * - Server API classes (com.arcadedb.*) are loaded from the parent class loader (shared)
+ * - Plugin-specific classes are loaded from the plugin's JAR first (isolated)
+ * - Other classes fall back to parent class loader if not found in plugin JAR
+ *
+ *
+ * Plugin Lifecycle
+ *
+ * Plugins follow this lifecycle:
+ *
+ * - Discovery - PluginManager scans lib/plugins/ directory
+ * - Loading - Each plugin JAR gets its own PluginClassLoader
+ * - Instantiation - ServiceLoader creates plugin instances
+ * - Configuration - {@link com.arcadedb.server.ServerPlugin#configure} is called
+ * - Starting - {@link com.arcadedb.server.ServerPlugin#startService} is called
+ * - Running - Plugin provides its functionality
+ * - Stopping - {@link com.arcadedb.server.ServerPlugin#stopService} is called
+ * - Cleanup - ClassLoaders are closed
+ *
+ *
+ * Creating a Plugin
+ *
+ * To create a new plugin:
+ *
{@code
+ * public class MyPlugin implements ServerPlugin {
+ * @Override
+ * public void configure(ArcadeDBServer server, ContextConfiguration config) {
+ * // Initialize configuration
+ * }
+ *
+ * @Override
+ * public void startService() {
+ * // Start plugin services
+ * }
+ *
+ * @Override
+ * public void stopService() {
+ * // Stop plugin services
+ * }
+ * }
+ * }
+ *
+ *
+ * Create {@code src/main/resources/META-INF/services/com.arcadedb.server.ServerPlugin}:
+ *
+ * com.example.MyPlugin
+ *
+ *
+ * Thread Safety
+ *
+ * The plugin system manages the thread context class loader during plugin operations to ensure
+ * proper class loading context. Plugin implementations should be thread-safe if they handle
+ * concurrent requests.
+ *
+ * @author Luca Garulli (l.garulli@arcadedata.com)
+ * @see com.arcadedb.server.ServerPlugin
+ * @see com.arcadedb.server.plugin.PluginManager
+ * @see com.arcadedb.server.plugin.PluginClassLoader
+ */
+package com.arcadedb.server.plugin;
diff --git a/server/src/test/java/com/arcadedb/server/plugin/PluginManagerTest.java b/server/src/test/java/com/arcadedb/server/plugin/PluginManagerTest.java
new file mode 100644
index 0000000000..2d6488e790
--- /dev/null
+++ b/server/src/test/java/com/arcadedb/server/plugin/PluginManagerTest.java
@@ -0,0 +1,437 @@
+/*
+ * Copyright © 2021-present Arcade Data Ltd (info@arcadedata.com)
+ *
+ * Licensed 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.
+ *
+ * SPDX-FileCopyrightText: 2021-present Arcade Data Ltd (info@arcadedata.com)
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.arcadedb.server.plugin;
+
+import com.arcadedb.ContextConfiguration;
+import com.arcadedb.GlobalConfiguration;
+import com.arcadedb.server.ArcadeDBServer;
+import com.arcadedb.server.ServerException;
+import com.arcadedb.server.ServerPlugin;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+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.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test for PluginManager to verify plugin discovery and loading with isolated class loaders.
+ *
+ * @author Luca Garulli (l.garulli@arcadedata.com)
+ */
+public class PluginManagerTest {
+ private ArcadeDBServer server;
+ private PluginManager pluginManager;
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeEach
+ public void setup() {
+ final ContextConfiguration configuration = new ContextConfiguration();
+ configuration.setValue(GlobalConfiguration.SERVER_ROOT_PATH, tempDir.toString());
+ configuration.setValue(GlobalConfiguration.SERVER_DATABASE_DIRECTORY, tempDir.resolve("databases").toString());
+ configuration.setValue(GlobalConfiguration.SERVER_PLUGINS,
+ TestPlugin1.class.getSimpleName() + "," +
+ TestPlugin2.class.getSimpleName() + "," +
+ LifecycleTestPlugin.class.getSimpleName() + "," +
+ AfterHttpPlugin.class.getSimpleName() + "," +
+ FailingPlugin.class.getSimpleName() + "," +
+ OrderTestPlugin1.class.getSimpleName() + "," +
+ OrderTestPlugin2.class.getSimpleName() + "," +
+ OrderTestPlugin3.class.getSimpleName() + "," +
+ BeforeHttpPlugin.class.getSimpleName());
+
+ server = new ArcadeDBServer(configuration);
+ pluginManager = new PluginManager(server, configuration);
+ }
+
+ @AfterEach
+ public void teardown() {
+ if (pluginManager != null) {
+ pluginManager.stopPlugins();
+ }
+ if (server != null && server.isStarted()) {
+ server.stop();
+ }
+ }
+
+ @Test
+ public void testPluginManagerCreation() {
+ assertNotNull(pluginManager);
+ assertEquals(0, pluginManager.getPluginCount());
+ }
+
+ @Test
+ public void testDiscoverPluginsWithNoDirectory() {
+ // Should handle missing plugins directory gracefully
+ pluginManager.discoverPlugins();
+ assertEquals(0, pluginManager.getPluginCount());
+ }
+
+ @Test
+ public void testGetPluginNames() {
+ final Collection names = pluginManager.getPluginNames();
+ assertNotNull(names);
+ assertTrue(names.isEmpty());
+ }
+
+ @Test
+ public void testGetPlugins() {
+ final Collection plugins = pluginManager.getPlugins();
+ assertNotNull(plugins);
+ assertTrue(plugins.isEmpty());
+ }
+
+ @Test
+ public void testStopPluginsWhenEmpty() {
+ // Should handle stopping with no plugins loaded
+ assertDoesNotThrow(() -> pluginManager.stopPlugins());
+ }
+
+ @Test
+ public void testDiscoverPluginsWithEmptyDirectory() throws IOException {
+ // Create empty plugins directory
+ final Path pluginsDir = tempDir.resolve("lib/plugins");
+ Files.createDirectories(pluginsDir);
+
+ pluginManager.discoverPlugins();
+ assertEquals(0, pluginManager.getPluginCount());
+ }
+
+ @Test
+ public void testLoadPluginWithMetaInfServices() throws Exception {
+ // Create a test plugin JAR with proper META-INF/services
+ final Path pluginsDir = tempDir.resolve("lib/plugins");
+ Files.createDirectories(pluginsDir);
+
+ final File pluginJar = createTestPluginJar(pluginsDir, "test-plugin", TestPlugin1.class);
+
+ pluginManager.discoverPlugins();
+
+ assertEquals(1, pluginManager.getPluginCount());
+ assertTrue(pluginManager.getPluginNames().contains(TestPlugin1.class.getSimpleName()));
+
+ final Collection plugins = pluginManager.getPlugins();
+ assertEquals(1, plugins.size());
+ }
+
+ @Test
+ public void testLoadMultiplePlugins() throws Exception {
+ final Path pluginsDir = tempDir.resolve("lib/plugins");
+ Files.createDirectories(pluginsDir);
+
+ createTestPluginJar(pluginsDir, "plugin1", TestPlugin1.class);
+ createTestPluginJar(pluginsDir, "plugin2", TestPlugin2.class);
+
+ pluginManager.discoverPlugins();
+
+ assertEquals(2, pluginManager.getPluginCount());
+ final Set names = pluginManager.getPluginNames();
+ assertTrue(names.contains(TestPlugin1.class.getSimpleName()));
+ assertTrue(names.contains(TestPlugin2.class.getSimpleName()));
+ }
+
+ @Test
+ public void testPluginLifecycle() throws Exception {
+ final Path pluginsDir = tempDir.resolve("lib/plugins");
+ Files.createDirectories(pluginsDir);
+
+ createTestPluginJar(pluginsDir, "lifecycle-plugin", LifecycleTestPlugin.class);
+
+ pluginManager.discoverPlugins();
+ assertEquals(1, pluginManager.getPluginCount());
+
+ // Start the plugin
+ pluginManager.startPlugins(ServerPlugin.PluginInstallationPriority.BEFORE_HTTP_ON);
+
+ // Verify plugin was configured and started
+ final PluginDescriptor descriptor = pluginManager.getPluginDescriptor(LifecycleTestPlugin.class.getSimpleName());
+ assertNotNull(descriptor);
+ assertTrue(descriptor.isStarted());
+ assertTrue(descriptor.getPluginInstance() instanceof LifecycleTestPlugin);
+
+ final LifecycleTestPlugin plugin = (LifecycleTestPlugin) descriptor.getPluginInstance();
+ assertTrue(plugin.configured.get());
+ assertTrue(plugin.started.get());
+ assertFalse(plugin.stopped.get());
+
+ // Stop the plugin
+ pluginManager.stopPlugins();
+ assertTrue(plugin.stopped.get());
+ assertFalse(descriptor.isStarted());
+ }
+
+ @Test
+ public void testPluginStartOrderByPriority() throws Exception {
+ final Path pluginsDir = tempDir.resolve("lib/plugins");
+ Files.createDirectories(pluginsDir);
+
+ createTestPluginJar(pluginsDir, "before-plugin", BeforeHttpPlugin.class);
+ createTestPluginJar(pluginsDir, "after-plugin", AfterHttpPlugin.class);
+
+ pluginManager.discoverPlugins();
+ assertEquals(2, pluginManager.getPluginCount());
+
+ // Start BEFORE_HTTP_ON plugins
+ pluginManager.startPlugins(ServerPlugin.PluginInstallationPriority.BEFORE_HTTP_ON);
+
+ PluginDescriptor beforeDesc = pluginManager.getPluginDescriptor(BeforeHttpPlugin.class.getSimpleName());
+ PluginDescriptor afterDesc = pluginManager.getPluginDescriptor(AfterHttpPlugin.class.getSimpleName());
+
+ assertTrue(beforeDesc.isStarted());
+ assertFalse(afterDesc.isStarted());
+
+ // Start AFTER_HTTP_ON plugins
+ pluginManager.startPlugins(ServerPlugin.PluginInstallationPriority.AFTER_HTTP_ON);
+
+ assertTrue(beforeDesc.isStarted());
+ assertTrue(afterDesc.isStarted());
+ }
+
+ @Test
+ public void testPluginWithoutMetaInfServices() throws Exception {
+ final Path pluginsDir = tempDir.resolve("lib/plugins");
+ Files.createDirectories(pluginsDir);
+
+ // Create JAR without META-INF/services
+ final File pluginJar = pluginsDir.resolve("invalid-plugin.jar").toFile();
+ try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(pluginJar))) {
+ // Just create an empty JAR
+ jos.putNextEntry(new JarEntry("dummy.txt"));
+ jos.write("test".getBytes());
+ jos.closeEntry();
+ }
+
+ pluginManager.discoverPlugins();
+
+ // Plugin should not be loaded due to missing META-INF/services
+ assertEquals(0, pluginManager.getPluginCount());
+ }
+
+ @Test
+ public void testPluginStartException() throws Exception {
+ final Path pluginsDir = tempDir.resolve("lib/plugins");
+ Files.createDirectories(pluginsDir);
+
+ createTestPluginJar(pluginsDir, "failing-plugin", FailingPlugin.class);
+
+ pluginManager.discoverPlugins();
+ assertEquals(1, pluginManager.getPluginCount());
+
+ // Starting the plugin should throw exception
+ assertThrows(ServerException.class, () ->
+ pluginManager.startPlugins(ServerPlugin.PluginInstallationPriority.BEFORE_HTTP_ON));
+ }
+
+ @Test
+ public void testGetPluginDescriptor() throws Exception {
+ final Path pluginsDir = tempDir.resolve("lib/plugins");
+ Files.createDirectories(pluginsDir);
+
+ createTestPluginJar(pluginsDir, "test-plugin", TestPlugin1.class);
+
+ pluginManager.discoverPlugins();
+
+ final PluginDescriptor descriptor = pluginManager.getPluginDescriptor(TestPlugin1.class.getSimpleName());
+ assertNotNull(descriptor);
+ assertEquals(TestPlugin1.class.getSimpleName(), descriptor.getPluginName());
+ assertNotNull(descriptor.getClassLoader());
+ assertNotNull(descriptor.getPluginInstance());
+ assertFalse(descriptor.isStarted());
+ }
+
+ @Test
+ public void testClassLoaderIsolation() throws Exception {
+ final Path pluginsDir = tempDir.resolve("lib/plugins");
+ Files.createDirectories(pluginsDir);
+
+ createTestPluginJar(pluginsDir, "plugin1", TestPlugin1.class);
+ createTestPluginJar(pluginsDir, "plugin2", TestPlugin2.class);
+
+ pluginManager.discoverPlugins();
+
+ final PluginDescriptor desc1 = pluginManager.getPluginDescriptor(TestPlugin1.class.getSimpleName());
+ final PluginDescriptor desc2 = pluginManager.getPluginDescriptor(TestPlugin2.class.getSimpleName());
+
+ // Each plugin should have its own class loader
+ assertNotNull(desc1.getClassLoader());
+ assertNotNull(desc2.getClassLoader());
+ assertNotSame(desc1.getClassLoader(), desc2.getClassLoader());
+
+ // Both should be PluginClassLoader instances
+ assertTrue(desc1.getClassLoader() instanceof PluginClassLoader);
+ assertTrue(desc2.getClassLoader() instanceof PluginClassLoader);
+ }
+
+ /**
+ * Helper method to create a test plugin JAR with proper META-INF/services
+ */
+ private File createTestPluginJar(final Path pluginsDir,
+ final String pluginName,
+ final Class extends ServerPlugin> pluginClass)
+ throws Exception {
+ final File jarFile = pluginsDir.resolve(pluginName + ".jar").toFile();
+
+ try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jarFile))) {
+ // Add the plugin class
+ final String classFileName = pluginClass.getName().replace('.', '/') + ".class";
+ jos.putNextEntry(new JarEntry(classFileName));
+
+ // Load class bytes from current classloader
+ try (InputStream is = getClass().getClassLoader().getResourceAsStream(classFileName)) {
+ if (is != null) {
+ is.transferTo(jos);
+ }
+ }
+ jos.closeEntry();
+
+ // Add META-INF/services/com.arcadedb.server.ServerPlugin
+ jos.putNextEntry(new JarEntry("META-INF/services/com.arcadedb.server.ServerPlugin"));
+ jos.write(pluginClass.getName().getBytes());
+ jos.closeEntry();
+ }
+
+ return jarFile;
+ }
+
+ // Test plugin implementations
+ public static class TestPlugin1 implements ServerPlugin {
+ @Override
+ public void startService() {
+ }
+ }
+
+ public static class TestPlugin2 implements ServerPlugin {
+ @Override
+ public void startService() {
+ }
+ }
+
+ public static class LifecycleTestPlugin implements ServerPlugin {
+ public final AtomicBoolean configured = new AtomicBoolean(false);
+ public final AtomicBoolean started = new AtomicBoolean(false);
+ public final AtomicBoolean stopped = new AtomicBoolean(false);
+
+ @Override
+ public void configure(ArcadeDBServer arcadeDBServer, ContextConfiguration configuration) {
+ configured.set(true);
+ }
+
+ @Override
+ public void startService() {
+ started.set(true);
+ }
+
+ @Override
+ public void stopService() {
+ stopped.set(true);
+ }
+ }
+
+ public static class BeforeHttpPlugin implements ServerPlugin {
+ @Override
+ public void startService() {
+ }
+
+ @Override
+ public PluginInstallationPriority getInstallationPriority() {
+ return PluginInstallationPriority.BEFORE_HTTP_ON;
+ }
+ }
+
+ public static class AfterHttpPlugin implements ServerPlugin {
+ @Override
+ public void startService() {
+ }
+
+ @Override
+ public PluginInstallationPriority getInstallationPriority() {
+ return PluginInstallationPriority.AFTER_HTTP_ON;
+ }
+ }
+
+ public static class FailingPlugin implements ServerPlugin {
+ @Override
+ public void startService() {
+ throw new RuntimeException("Plugin failed to start");
+ }
+ }
+
+ public static class OrderTestPlugin1 implements ServerPlugin {
+ public static final AtomicInteger stopCounter = new AtomicInteger(0);
+ public static final AtomicInteger stopOrder = new AtomicInteger(0);
+
+ @Override
+ public void startService() {
+ }
+
+ @Override
+ public void stopService() {
+ stopOrder.set(stopCounter.incrementAndGet());
+ }
+ }
+
+ public static class OrderTestPlugin2 implements ServerPlugin {
+ public static final AtomicInteger stopCounter = new AtomicInteger(0);
+ public static final AtomicInteger stopOrder = new AtomicInteger(0);
+
+ @Override
+ public void startService() {
+ }
+
+ @Override
+ public void stopService() {
+ stopOrder.set(stopCounter.incrementAndGet());
+ }
+ }
+
+ public static class OrderTestPlugin3 implements ServerPlugin {
+ public static final AtomicInteger stopCounter = new AtomicInteger(0);
+ public static final AtomicInteger stopOrder = new AtomicInteger(0);
+
+ @Override
+ public void startService() {
+ }
+
+ @Override
+ public void stopService() {
+ stopOrder.set(stopCounter.incrementAndGet());
+ }
+ }
+}