Skip to content

inter-Extension dependency support#16973

Merged
georgew5656 merged 21 commits intoapache:masterfrom
georgew5656:extensionDependencySupport
Sep 24, 2024
Merged

inter-Extension dependency support#16973
georgew5656 merged 21 commits intoapache:masterfrom
georgew5656:extensionDependencySupport

Conversation

@georgew5656
Copy link
Copy Markdown
Contributor

Allow specifying extensions between extensions without having to explicitly install jars in multiple places.

Description

Currently, in order to have extension A depend on extension B, you have to explicitly include extension B as a build dependency in extension A's pom.xml. This cause the jar for extension B to be included in extension A's directory and loaded as a module.

This can lead to some weird behavior e.g. the following bug linked from #16929 caused by druid-kafka-extraction-namespace depending on druid-lookups-cached-global.

There is logic in ExtensionsLoader.tryAdd that checks whether a module has already been loaded during initialization and skips it if it already has been loaded.

This is a problem when both druid-kafka-extraction-namespace and druid-lookups-cached-global are specified because they both load NamespaceExtractionModule.

If druid-kafka-extraction-namespace is specified first, both NamespaceExtractionModule and KafkaExtractionNamespaceModule are loaded by the druid-kafka-extraction-namespace classloader, and the druid-lookups-cached-global classloader doesn't load anything since NamespaceExtractionModule was already loaded. This is fine because the features of druid-lookups-cached-global are served through the module NamespaceExtractionModule being loaded in druid-kafka-extraction-namespace. (this is essentially the same behavior as just loading druid-kafka-extraction-namespace, and this is why loading both extensions in this order works)

If druid-lookups-cached-global is specified first, NamespaceExtractionModule is loaded by the druid-lookups-cached-global class loader. The druid-kafka-extraction-namespace classloader will only load KafkaExtractionNamespaceModule because NamespaceExtractionModule has already been loaded. This is a problem because kafka lookups rely on classes bound in NamespaceExtractionModule that it can't access (because NamespaceExtractionModule is only bound in the druid-lookups-cached-global classloader).

to get around this issue, my proposed fix is to allow chaining classloaders without having to actually copy jars. e.g. extension A won't actually have extension B's jar (we include the dependency as provided in the pom.xml). when extension A is loading classes, it will try to use extension B's classloader to find classes it can't find.

Fixed the bug ...

Renamed the class ...

Added a forbidden-apis entry ...

Classloaders
We currently support two kinds of classloaders for each extension (extension-first or not). hadoop always uses the non extension-first classloader and other druid services check the druid.extensions.useExtensionClassloaderFirst property.

I made a assumption to simplify the change in addAllFromFileSystem() (the code in ExtensionsLoader where chained classloaders are set up). in this code we always use the classloader based off of druid.extensions.useExtensionClassloaderFirst, so if druid.extensions.useExtensionClassloaderFirst=true, the StandardClassLoader objects won't get chained classloading setup.

It seems like this could be a issue with this line in HadoopTask (https://github.com/apache/druid/blob/master/indexing-service/src/main/java/org/apache/druid/indexing/common/task/HadoopTask.java#L158), because if druid.extensions.useExtensionClassloaderFirst=true is set, only the ExtensionFirstClassLoaders will have chained classloading setup, but it seems like all HadoopTask does is combine all the resource urls from all the extensions so I don't think this is a issue. plus this is the current behavior.

this made it a little more annoying to implement chained classloading (see ExtensionLoader), but I didn't think we could deprecate this behavior so I left it in. I had to add a StandardClassLoader class because we were just using a regular UrlClassLoader object for the non extension-first classloader. In both StandardClassLoader and ExtensionFirstClassLoader i added the logic to check other extension's classloaders for classes. this is the part of the code i'd like some more detailed feedback on.

Extension dependency specification
I added a extension-dependencies.json per-extension resources file to specify inter-extension dependencies (see extensions-core/kafka-extraction-namespace/src/main/resources/extension-dependencies.json for a example)

When loading extensions, ExtensionsLoader looks for a druid-* jar (the main extension code), and inspects it for a extension-dependencies.json resource file. If this file exists, it injects the correct chained classloaders to the extension classloader.

I tested this with druid-kafka-extraction-namespace and druid-lookups-cached-global and everything seems to work okay.

I haven't added any unit tests yet because I want to get feedback on whether this approach seems reasonable.

Release note

  • support explicit extension dependencies in druid
Key changed/added classes in this PR
  • ExtensionLoader
  • ExtensionFirstClassLoader
  • StandardClassLoader
  • PullDependencies

This PR has:

  • been self-reviewed.
  • added documentation for new or modified features or behaviors.
  • a release note entry in the PR description.
  • added Javadocs for most classes and all non-trivial methods. Linked related entities via Javadoc links.
  • added or updated version, license, or notice information in licenses.yaml
  • added comments explaining the "why" and the intent of the code wherever would not be obvious for an unfamiliar reader.
  • added unit tests or modified existing tests to cover new code paths, ensuring the threshold for code coverage is met.
  • added integration tests.
  • been tested in a test Druid cluster.

Copy link
Copy Markdown
Contributor

@suneet-s suneet-s left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice! I'm not best positioned to actually review the change, but it would be good to add this to the instructions how to build an extension.

Closest thing I found to something like this was

### Managing dependencies

);
}

for (File extension : getExtensionFilesToLoad()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be cleaner to iterate extensionClassLoaderMap.entrySet() here so we don't need to re-read the extensions directory and re-call getClassLoaderForExtension.

{
for (URL url : loader.getURLs()) {
File jarFileLocation = new File(url.getPath());
if (jarFileLocation.getName().startsWith("druid")) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is IMO too magical; better to look at all jars. If there is more than one jar with the file then throw an error. Third party extensions especially may not start with druid.

{
private static final Logger log = new Logger(ExtensionsLoader.class);

public static final String EXTENSION_DEPENDENCIES_JSON = "extension-dependencies.json";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should have druid somewhere in the name, like druid-extension-dependencies.json. It should also be in resources/ inside the jar.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think the resources files get packaged into the root level of the jar, at least thats what i saw from unpacking the jar.

so in this case druid-extension-dependencies.json would be directly unzipped from the jar, not under a resources/ directory. i updated the name though

String entryName = entry.getName();

if (!entry.isDirectory() && EXTENSION_DEPENDENCIES_JSON.equals(entryName)) {
log.info("Found extension dependency entry in druid jar %s", url.getPath());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce logging chatter, make this log.debug and add the dependency info to the Loading extension [%s], jars: %s message.

if (!extensionClassLoaderMap.containsKey(druidExtensionDependency)) {
throw new RE(
StringUtils.format(
"%s depends on %s which is not a valid extension or not loaded.",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

House style for error messages includes square brackets around %s, with no whitespace before the brackets. So something like Extension[%s] depends on extension[%s], which is not present

}
chainedClassLoadersForExtension.add(extensionClassLoaderMap.get(druidExtensionDependency));
}
((StandardClassLoader) loader).setExtensionDependencyClassLoaders(chainedClassLoadersForExtension);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little confusing, so it should be explained with a comment somewhere. Perhaps as javadoc on addAllFromFileSystem() itself. The comment should call out that what happens is first we create classloaders for each extension directory, then we loop through and modify them to link in the dependency classloaders.

This should also be called out on the javadoc for getClassLoaderForExtension, since callers of that would need to know that dependency classloaders aren't necessarily linked in. (It depends on whether getFromExtensions() was called too.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this is so weird that we should try to change it. It creates a situation where the classloader you get may not be usable unless you also call getFromExtensions(), which is weird. Perhaps we can deal with this by making getClassLoaderForExtension private, and then adding a new method that gets the jar paths (not a classloader), which is what the Hadoop task wants anyway. And which doesn't have these problems.

@georgew5656 georgew5656 requested a review from gianm September 13, 2024 18:42
JarEntry entry = entries.nextElement();
String entryName = entry.getName();
if (DRUID_EXTENSION_DEPENDENCIES_JSON.equals(entryName)) {
log.debug("Found extension dependency entry in druid jar %s", extensionFile.getPath());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just "in jar" suffices, since we aren't filtering down to specifically Druid jars anymore.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

House style for error messages would include brackets around %s.

if (druidExtensionDependencies != null) {
throw new RE(
StringUtils.format(
"The extension [%s] has multiple druid jars with dependencies in it. Each jar should be in a separate extension directory.",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, "multiple jars" is better than "multiple druid jars". Also, list the jars? Otherwise the error message is going to be potentially confusing.

}
}
catch (IOException e) {
throw new RuntimeException(e);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Include the extension path in the error message, like throw new RE(e, "Failed to get dependencies for extension[%s]", extension);

extensionDependencyStack.add(extensionDependencyFile.getName());
throw new RE(
StringUtils.format(
"[%s] has a circular druid extension dependency. Dependency stack [%s].",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extension[%s] is better than just [%s].

if (!extensionDependencyFileOptional.isPresent()) {
throw new RE(
StringUtils.format(
"[%s] depends on [%s] which is not a valid extension or not loaded.",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extension[%s] is better than just [%s].

return getClassLoaderForExtension(extension, useExtensionClassloaderFirst, new ArrayList<>());
}

public StandardURLClassLoader getClassLoaderForExtension(File extension, boolean useExtensionClassloaderFirst, List<String> extensionDependencyStack)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Javadoc please, explaining what extensionDependencyStack is and how it's used. It isn't obvious from the context.

private final ConcurrentHashMap<Class<?>, Collection<?>> extensions = new ConcurrentHashMap<>();

@MonotonicNonNull
private File[] extensionFilesToLoad;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread-safety stance of this class is confusing: loaders and extensions are ConcurrentHashMap, which suggests some thread-safety requirements, whereas extensionFilesToLoad is not mutated in a thread-safe way. Also, this patch changes mutation of loaders from computeIfAbsent (atomic) to get + put (not atomic), which alters the thread-safety properties.

Anyway, I'm not sure if this class really needs to be thread-safe, but it was in the past, so we might as well keep in that way. Looking at how it's used, it isn't likely to be a point of thread contention, so it doesn't need fancy thread-safety. A single lock we are synchronized around should be fine. So I suggest making the ConcurrentHashMap into HashMap, marking all the mutable fields as @GuardedBy(this), and synchronizing around this.

@georgew5656 georgew5656 requested a review from gianm September 24, 2024 14:41

@VisibleForTesting
public Map<Pair<File, Boolean>, URLClassLoader> getLoadersMap()
public Map<Pair<File, Boolean>, StandardURLClassLoader> getLoadersMap()

Check notice

Code scanning / CodeQL

Exposing internal representation

getLoadersMap exposes the internal representation stored in field loaders. The value may be modified [after this call to getLoadersMap](1).
Copy link
Copy Markdown
Contributor

@gianm gianm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for the change!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants