Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance Groovy classloader experience #4596

Merged
merged 22 commits into from
Oct 16, 2023

Conversation

niloc132
Copy link
Member

@niloc132 niloc132 commented Oct 4, 2023

Introduces new features when using Groovy as a console language. This
should not remove or change existing features at all, but only serves
to backfill the removed source() and sourceOnce() functions with an
approach that encourages users to write real groovy scripts.

Makes it possible to load .groovy files from the classpath (or a custom
classloader) as classes. Standalone groovy scripts provide a static
main() method (and an instance run() method) to invoke one or more
times, and classes can be reimported to read fresh from disk. Includes
a feature where groovy files loaded from the classpath can use
Deephaven default imports, and can inherit imports from the console,
both disabled by default.

Replaces a list of import statements with the Groovy ImportCustomizer,
so that evaluated code doesn't have a variable number of prefix lines.
This has the advantage of also being applicable to scripts loaded from
the classpath.

Restored unit tests lost when migrating to the deephaven-core codebase,
which helped to validate some of these cases, and verify
console<->querylanguage classloader interactions.

private static final boolean ALLOW_UNKNOWN_GROOVY_PACKAGE_IMPORTS = Configuration.getInstance()
.getBooleanForClassWithDefault(GroovyDeephavenSession.class, "allowUnknownGroovyPackageImports", false);

private static final ClassLoader STATIC_LOADER =
new URLClassLoader(new URL[0], GroovyDeephavenSession.class.getClassLoader()) {
new URLClassLoader(new URL[0], Thread.currentThread().getContextClassLoader()) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Note for reviewers (or future me, who forgot this fact again): SomeClass.class.getClassLoader() is only the correct answer when trying to say "load a thing from the same classloader that this class came from", usually for something like getting a resource known to be in the same jar as this class. The context classloader is almost always preferred for "load with the classloader currently being used", since this lets a caller replace the current classloader with a more specific one (that probably points to getClass().getClassLoader() as a parent or other ancestor, so the same class can still be loaded).

try {
return Class.forName(className, false, GroovyDeephavenSession.class.getClassLoader());
return Class.forName(className, false, this.groovyShell.getClassLoader());
Copy link
Member Author

Choose a reason for hiding this comment

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

As above, a custom classloader installed by the app before calling this wouldn't necessarily be called when a class needs to be loaded in this way. Between this and the above change, not only does groovy get a chance to compile if needed, but a custom classloader can also be used.

@@ -288,7 +384,7 @@ private static Class<?> loadClass(String className) throws ClassNotFoundExceptio
}
}

private static boolean classExists(String className) {
private boolean classExists(String className) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Reviewer note: these could be made static again if the classloader was passed as an argument, it is needed in loadClass.

private void addDefaultImports(ImportCustomizer imports) {
// TODO (core#230): Remove large list of manual text-based consoleImports
// NOTE: Don't add to this list without a compelling reason!!! Use the user script import if possible.
imports.addImports(
Copy link
Member Author

Choose a reason for hiding this comment

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

Reviewer note: I made this a list of class literals to assist in future refactoring, but can revert to just putting a list of strings for each of these.

Copy link
Member

Choose a reason for hiding this comment

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

You could introduce a helper method to invoke getName automatically, but this doesn't look like it will be gross to maintain as is =P.


@After
public void tearDown() {
executionContext.close();
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure how we got away with not closing the exec context before, are we possibly just opening other context on top of it and never checking that none were leaked?

Copy link
Member

Choose a reason for hiding this comment

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

Removing is helpful to ensure that we are setting it before every test that needs it. It is otherwise not an error to not "close" an open execution at this time. Thanks for finding that it was missing here!

@rcaudy rcaudy added this to the September 2023 milestone Oct 5, 2023
@niloc132
Copy link
Member Author

niloc132 commented Oct 5, 2023

Here's an extremely hacky example classloader that produces groovy files on the fly, which could easily be a blocking call to read network/table data. Set this up before DHC has started, so that it will be used to load groovy files as requested by the DHE groovy IDE.

        final Path dir = Files.createTempDirectory("dhc");

        Thread.currentThread().setContextClassLoader(new ClassLoader(Thread.currentThread().getContextClassLoader()) {
            @Nullable
            @Override
            public URL getResource(String name) {
                // if this is a groovy file, pretend to load contents remotely, and return a file: URL
                // to it, otherwise groovy won't parse it.
                if (name.startsWith("notebook/")) {// be sure not to take over deephaven/groovy/java files
                    Path path = Path.of(name);
                    String packageString = StreamSupport.stream(path.getParent().spliterator(), false)
                            .map(Path::toString).collect(Collectors.joining("."));
                    Path fileName = path.getParent().relativize(path);
                    if (fileName.endsWith("Script.groovy")) {// this check doesn't make sense for a real use case, but
                                                             // we're just going to make a fake file
                        Path result = dir.resolve(path);
                        try {
                            if (!Files.exists(result)) {
                                Files.createDirectories(result.getParent());
                                System.out.println(result);
                                Files.writeString(result, "package " + packageString + "\nprintln 'hello, world'\n");
                            }
                            return result.toFile().toURL();
                        } catch (IOException e) {
                            // instead of failing, possibly log, then pass the buck to the next link in the chain
                        }
                    }
                }

                return super.getResource(name);
            }
        });

@niloc132 niloc132 requested review from devinrsmith and rcaudy October 5, 2023 21:28
@niloc132 niloc132 added the release blocker A bug/behavior that puts is below the "good enough" threshold to release. label Oct 9, 2023

@After
public void tearDown() {
executionContext.close();
Copy link
Member

Choose a reason for hiding this comment

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

Removing is helpful to ensure that we are setting it before every test that needs it. It is otherwise not an error to not "close" an open execution at this time. Thanks for finding that it was missing here!

// import is not optional, ; is optional;
"static io.deephaven.engine.util.scripts.TestGroovyDeephavenSession.StaticClass",
// groovy is not cool with non-star package imports for packages that don't exist
"import com.illumon.foo.bar;",
Copy link
Member

Choose a reason for hiding this comment

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

io.deephaven? we probably don't want to reference illumon in these tests

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, but the point is that the package does not exist...

Copy link
Member

Choose a reason for hiding this comment

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

Technically, io.deephaven.foo.bar does not exist. I'm just considering all of the effort we went through to remove references to illumon and db, for example.


c = "t = emptyTable(1).updateView(\"Y=" + Y + "\", \"Z=max(Y, d)\")\n";
try {
QueryScope.addParam("z", z);
Copy link
Member

Choose a reason for hiding this comment

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

I don't understand why you're addParam on each test - in this particular case you're setting z but looks unused. Why not set all parameters once at the top of the method?

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed, this seems a bit off, but is part of the original test from DHE. I tried to keep my changes here limited, ensuring that except where I explicitly changed something, the same things that pass for DHE passes in DHC.

}
}
} else {
okToImport = classExists(body);
if (classExists(body)) {
Copy link
Member

Choose a reason for hiding this comment

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

I'd collapse this with the line above to reduce a level of indentation:

} else if (classExists(body)) {
// ...
} else {
// ...
}

if (isWildcard) {
result = Optional.of(imports -> imports.addStaticStars(body));
} else {
if (alias != null) {
Copy link
Member

Choose a reason for hiding this comment

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

I would collapse this if with else above.

Copy link
Member Author

Choose a reason for hiding this comment

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

Or drop the else entirely and just return once? no opinions offered either way, so I'm going to apply that change and your suggestions...

private void addDefaultImports(ImportCustomizer imports) {
// TODO (core#230): Remove large list of manual text-based consoleImports
// NOTE: Don't add to this list without a compelling reason!!! Use the user script import if possible.
imports.addImports(
Copy link
Member

Choose a reason for hiding this comment

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

You could introduce a helper method to invoke getName automatically, but this doesn't look like it will be gross to maintain as is =P.

@@ -68,6 +68,7 @@ dependencies {
testImplementation project(':extensions-parquet-table')
testImplementation project(':extensions-source-support')
testImplementation project(':Numerics')
testImplementation project(':extensions-suanshu')
Copy link
Contributor

Choose a reason for hiding this comment

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

do we still need this? thought it was deprecated

Copy link
Member Author

Choose a reason for hiding this comment

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

Added for test classpaths only, as an example used in DHE tests for groovy parameter disambiguation hit one of these methods.

* Adds the default imports that Groovy users assume to be present.
*/
private void addDefaultImports(ImportCustomizer imports) {
// TODO (core#230): Remove large list of manual text-based consoleImports
Copy link
Contributor

Choose a reason for hiding this comment

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

Does this PR close #230?

Copy link
Member Author

Choose a reason for hiding this comment

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

No, just moves it to here rather than a config entry.

Copy link
Member

@devinrsmith devinrsmith left a comment

Choose a reason for hiding this comment

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

LGTM, leaving approval for others.

nbauernfeind
nbauernfeind previously approved these changes Oct 13, 2023
rbasralian
rbasralian previously approved these changes Oct 16, 2023
@niloc132 niloc132 dismissed stale reviews from rbasralian and nbauernfeind via 7c9b700 October 16, 2023 21:49
@niloc132 niloc132 merged commit a9abaf6 into deephaven:main Oct 16, 2023
10 checks passed
@github-actions github-actions bot locked and limited conversation to collaborators Oct 16, 2023
@deephaven-internal
Copy link
Contributor

Labels indicate documentation is required. Issues for documentation have been opened:

How-to: https://github.com/deephaven/deephaven.io/issues/3304
Reference: https://github.com/deephaven/deephaven.io/issues/3303

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
console DocumentationNeeded groovy release blocker A bug/behavior that puts is below the "good enough" threshold to release. ReleaseNotesNeeded Release notes are needed
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants