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

Add JsPlugin and Jetty js-plugins #2908

Closed
wants to merge 21 commits into from

Conversation

devinrsmith
Copy link
Member

@devinrsmith devinrsmith commented Sep 26, 2022

The JsPlugin is a server plugin that has a distribution directory, name, version, and main. main is a relative location with respect to the distribution directory that points to the main js file to load.

Jetty infrastructure support for JsPlugin is provided via HTTP js-plugins/*. (This feature is not available with Netty.) It can be explicitly disabled by setting -Dhttp.jsPlugins=false.

For more information about the JS expectations and requirements, see https://github.com/deephaven/deephaven-js-plugin-template/

Currently, there are few ways to register a JsPlugin:

  1. via a Deephaven python plugin entry_points, see https://github.com/deephaven/deephaven-plugin
  2. via the system property io.deephaven.server.plugin.type.JsPluginDistribution, which is a comma separated list of directories that contain package.json files.
  3. via a Java service loader for the io.deephaven.plugin.type.JsPlugin class

@devinrsmith devinrsmith added python DocumentationNeeded plug-ins javascript Pull requests that update Javascript code labels Sep 26, 2022
@devinrsmith devinrsmith added this to the Sept 2022 milestone Sep 26, 2022
@devinrsmith devinrsmith self-assigned this Sep 26, 2022
@devinrsmith
Copy link
Member Author

Depends on deephaven/deephaven-plugin#7

@devinrsmith
Copy link
Member Author

Integration tests failing - need to bump base images after initial round of reviews. deephaven/deephaven-base-images#36

* @throws IOException if an I/O exception occurs
*/
public static JsPlugins create() throws IOException {
final JsPlugins jsPlugins = new JsPlugins(Files.createTempDirectory(JsPlugins.class.getName()));
Copy link
Member

Choose a reason for hiding this comment

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

We very likely don't want to use the conventional temp system - we've historically seen installations with cron jobs or the like that nuke temp space. The exception here could be if we held open FDs or something so that the files can't be deleted out from under us, but that seems wasteful. Given the expected load (likely not very high?), it might be okay to serve from the py directories directly?

If we trust the contents enough to copy them directly, we could consider just adding more Resources to the WebAppContext, and skip the extra DefaultServlet - see Resource.newResource(...) for options to build one of these that can point to a directory or even inside a zip. If we don't implicitly trust the contents of a wheel (soft links, etc, see the other remark in this file), we should only unpack or serve "real" files, and need a more complex system anyway.

Also, consider a wheel being added after startup, since python will correctly load (and i think register them). I think this works as-is today (edit: except for writing the manifest!), but possibly worth checking that case.

Copy link
Member Author

Choose a reason for hiding this comment

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

We can't rely on py directories - they aren't guaranteed to be actual directories. This also allows us to eventually package stuff up in java jars if we need to; not generally accessible directly via the FS apis. (The resource abstraction that python presents uses a context manager / generator that allows temporary access to a pathlib.)

Reading the jetty documentation, they strongly recommend using DefaultServlet for static resources instead of Resource directly. I'll try and find that and link.

As it stands, we don't have dynamic support for our plugin infra. It's definitely a thing we can investigate in the future, but this isn't expected to work today. #2816

Copy link
Member Author

Choose a reason for hiding this comment

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

It looks like even with Resource.newResource(...), everything is reference based which we don't want when dealing with python resources.

I'm not sure if there is a pre-built "serve this content from an in-memory string". Of course, we could write that servlet logic ourselves.

Being able to take advantage of DefaultServlet is nice - my comment earlier about "they strongly recommend..." was a bit incorrect, but they do say this, https://www.eclipse.org/jetty/documentation/jetty-11/programming-guide/index.html#pg-server-http-handler-use-default-servlet:

you may want to use a DefaultServlet instead of ResourceHandler

I'll continue digging to see if there is a good pre-existing solution for in-memory content.

As an alternative to temporary directory, we can use the cache directory we setup as temporary space.

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually - org.eclipse.jetty.util.resource.PathResource#PathResource(java.nio.file.Path) seems like a solution if it actually works with non-filesystem "Path"s (ala something like https://github.com/google/jimfs). There is some hope that it "just works", given I see this in the constructor:

this.belongsToDefaultFileSystem = this.path.getFileSystem() == FileSystems.getDefault();

Copy link
Member

Choose a reason for hiding this comment

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

Reading the jetty documentation, they strongly recommend using DefaultServlet for static resources instead of Resource directly. I'll try and find that and link.

Given that DefaultServlet wraps a Resource, and we already have a DefaultServlet, I would be surprised by this. We used to only set a directory for our resource base (not to be confused with base resource), but ... that causes jetty to unpack contents to temp, and that breaks when temp gets cleaned up.

Am I incorrect that installing a new wheel at runtime will not try to call register? should we check for calling register after init and then throw or ignore it?

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've successfully used an in-memory Path (jimfs) so we don't need to deal w/ tmpfs.

Installing a wheel at runtime will not auto-register. That said, the raw plugin architecture (register(...)) does support that functionality in theory, it just needs to be plumbed through. #2816

final Map<String, String> p = new LinkedHashMap<>(3);
p.put("name", value.name());
p.put("version", value.version());
p.put("main", value.main());
Copy link
Member

Choose a reason for hiding this comment

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

would be nice to also send the "distribution path" relative to /js-plugins/? or are we asserting that name is always the subdirectory of js-plugins, and the web client should do the string concat directly, and that names will never contain chars that aren't url-safe?

Copy link
Member Author

Choose a reason for hiding this comment

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

The JS side right now assumes the path is relative to js-plugins/<name>. This is the convention that @mofojed has established - but I'm fine to change it if we want.

Happy to add validation for name; are all NPM package names url safe? Note, the names do contain @ symbol and that works ok.

Copy link
Member

Choose a reason for hiding this comment

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

slashes are legal too iirc, but i dont think we want to encourage subdirectories? Also, since we want to allow non-npm modules, we might need to handle collisions, perhaps by generating dir names.

Copy link
Member Author

Choose a reason for hiding this comment

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

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 dealing with collisions is something I'm practically concerned with right now. We could generate random prefixes (or full paths) to deal with that, but it seems like overkill ATM.

How does js plugin resolution work? For example, let's say we accidentally installed two separate versions of the js-plugin-plotly NPM package (and we "allowed" this by distinguishing them with random prefixes), how would the web UI handle this?

@mofojed

Copy link
Member

Choose a reason for hiding this comment

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

I am not concerned about multiple versions of same package right now. If we need that, can do aliasing.

The layout is similar to how node_modules is laid out right now - the subdirectory is intentional, as then all @deephaven plugins are in the same dir (scope)

p.put("main", value.main());
pluginsList.add(p);
}
new ObjectMapper().writeValue(out, Collections.singletonMap("plugins", pluginsList));
Copy link
Member

Choose a reason for hiding this comment

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

while technically most usage of "plugins" is probably fair game, this is one that seems better to be more specific as "web content" or the like, since this isn't a complete list of plugins at all

Copy link
Member Author

Choose a reason for hiding this comment

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

This is the convention established by @mofojed , happy to change.

Copy link
Member

Choose a reason for hiding this comment

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

Right, but DHE had "plugins" before DHC introduced Plugin as the supertype to ObjectType, so it had meaning there already. Given that plugin != plugin, and this is intended to be a user-facing api (as we intend to encourage community development of ways to improve the platform?) we should disambiguate one way or the other.

I called out just a few cases in the patch where I think we cross a line of confusion, this being one.

Copy link
Member

Choose a reason for hiding this comment

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

This manifest.json is in a js-plugins folder already, making it clear it's js plugins. I don't think this is a problem in this case, as the context for the manifest already implies what kind of plugins they are.

@devinrsmith devinrsmith changed the title Add JsType plugin Add JsPlugin and Jetty js-plugins Oct 7, 2022
mofojed
mofojed previously approved these changes Oct 7, 2022
Copy link
Member

@mofojed mofojed left a comment

Choose a reason for hiding this comment

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

Looking good. Tried it out locally:

  • Set up jetty with a python venv (the instructions I got from Slack, the docs around this could be improved as we discussed)
  • Did a pip install deephaven-plugin-plotly to install the python plotly plugin
  • Tested with local JS plugin by adding extraJvmArgs += ['-Dio.deephaven.server.plugin.type.JsPluginDistribution=/Users/bender/dev/deephaven/oss/deephaven-js-plugins/plotly'] to server-jetty-app/build.gradle, was able to open figures
  • Tested after with a pip install deephaven-plugin-plotly-js to install the JS plugin via python wrapper, was able to open figures

The code snippet I used to create a figure was:

import plotly.express as px
df = px.data.iris()
fig = px.scatter(df, x="sepal_width", y="sepal_length", color="species",
                 size='petal_length', hover_data=['petal_width'])

py/server/setup.py Outdated Show resolved Hide resolved
devinrsmith added a commit to deephaven/deephaven-plugin that referenced this pull request Oct 12, 2022
Tested in combination with deephaven/deephaven-core#2908; published .dev versions to PyPi for downstream consumption.

Adds the python class deephaven.plugin.js.JsPlugin. This allows for the development of JS plugins which can be provided by python packages. See https://github.com/deephaven/js-plugin-template for JS expectations and requirements.
mofojed
mofojed previously approved these changes Oct 13, 2022
*/
public static JsPlugins create() throws IOException {
final FileSystem fs = Jimfs.newFileSystem(Configuration.unix());
final JsPlugins jsPlugins = new JsPlugins(fs.getPath("/"));
Copy link
Member

Choose a reason for hiding this comment

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

Is there ever a case where JsPlugins would be created with a different path? Would it make any sense to just pass the fs itself, or do the creation internally? The private ctor doesn't seem to add anything, new would be fewer chars, no meaningful difference (since there isnt another factory method etc).

Also, is it safe to assume that Configuration.unix() behaves well with mac and windows, no assumptions will be made that will give us a headache later?

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 had another code path I was testing with earlier - a temporary local directory that was being created. If we add unit testing, we'll probably need to extend our construction techniques to better clean-up.

I'm not sure if this is a personal bias, or recommended best-practices I've seen elsewhere; but I try and keep my constructors as simple setters and simple object creation, with necessary supporting logic and initialization in static methods.

Copy link
Member Author

Choose a reason for hiding this comment

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

That's a good question - I wonder if we are better off choosing the in-memory representation that more closely matches the host operating system. I'll look into it a bit more, specifically wrt COPY_ATTRIBUTES

Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure we want all attributes, but I'm not sure we don't, just want to be sure we decide rather than get the default.

Temp dir seems great too, though I'd hesitate about not testing the actual paths we intend to use, since jimfs by design isn't "the real thing", and those oddities might surprise us at runtime.

Thoughts about just making an on-disk zip file of the dir structure we need, and using that as a filesystem? Only one file need be created, and I believe that the zip fs impl holds open a handle, so /tmp being deleted wouldnt hurt us, and we wouldn't need to worry about having tons of and/or giant files in memory.

No objection to simple object creation, just voting for consistency of the class's own API. I tend to agree that if the logic is at all non-trivial, use a nicely named factory, but if there is a simple variant then also give it an appropriately named factory 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.

I've got zip filesystem plumbed though now, I like it more than jimfs (even though jimfs is cool, it's an extra dependency).

public interface JsPluginsModule {

@Provides
static JsPluginRegistration providesRegistration(JettyConfig config, JsPlugins plugins) {
Copy link
Member

Choose a reason for hiding this comment

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

not a singleton? in theory at least this could get hit more than once, with different JsPluginRegistrationNoOp instances? If you don't want it a singleton here, maybe make the noop into an enum or the like?

Copy link
Member Author

Choose a reason for hiding this comment

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

Structurally, I think this method should "delegate" to the singleton (or non-singleton) nature of the injected JsPlugins - that said, I'll improve JsPluginRegistrationNoOp as suggested.

Files.walkFileTree(src, new CopyRecursiveVisitor(src, dst));
}

static final class PackageJson {
Copy link
Member

Choose a reason for hiding this comment

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

conventions should be documented, either in this repo or possibly in https://github.com/deephaven/js-plugin-template? esp since we hardcode the name, and fail on unknown properties (so "real" package.json files will fail it)

Copy link
Member Author

Choose a reason for hiding this comment

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

There is a bit of docs in io.deephaven.server.plugin.type.JsPluginDistribution#fromPackageJsonDistribution saying that name, version, and main are used from package.json. I think linking out to this repository makes sense though, will do that.

Copy link
Member

Choose a reason for hiding this comment

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

Sure, but that's in the guts of the java code - the author of a js plugin is going to look at js-plugin docs, not javadocs of a given class on the platform?

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'll add a link at JsPlugin level.

Copy link
Member

Choose a reason for hiding this comment

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

What I'm saying is that we need more than Javadoc for this - a JS/TS developer shouldn't have to already know how java/py works to be able to figure this out.

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 expect JS/TS dev being able to necessarily decipher through this either. I hope the external js plugin documentation will be well suited for them, and then we can have docs for "here's how to convert it to PyPi package or jar for easy inclusion".

To confirm, we don't expect that a JS dev will be able to work out how to produce a jar/zip or a wheel, or what the format should be of the package.json? I don't think that's what you're saying, but it seems to be.

I'm not asking for "lots of java tutorials", but just "here's the shortest path-to-success to produce a js plugin for deephaven, what files matter, what fields matter, why your own package.json isn't valid, just a subset of it". Mostly "you can have a name, version, and main, we'll effectively ignore the version, and use the main as a relative path from your package.json file", etc. Such a plugin might have no py and no java, and no plan to add either, but wants to know the degrees of freedom they should have?

Maybe you're saying you'll file something against js-plugins to write this up? That seems like a good place, as long as it is agnostic to the language (since this should be strictly declarative)?

* @return the js plugin distribution
* @throws IOException if an I/O exception occurs
*/
public static JsPluginDistribution fromDistribution(Path distributionDir) throws IOException {
Copy link
Member

Choose a reason for hiding this comment

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

consider both ctors, or both factory methods? one of each feels weird

Copy link
Member Author

Choose a reason for hiding this comment

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

Will do.

String.format("js plugin with name '%s' already exists", jsPlugin.name()));
}
try {
jsPlugin.copyTo(path.resolve(jsPlugin.name()));
Copy link
Member

Choose a reason for hiding this comment

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

if fs, then fs.getPath(jsPlugin.name())

Copy link
Member

Choose a reason for hiding this comment

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

also, consider finer-grained exceptions where they happen ("what if src doesnt exist", "what if files can't be read" etc), rather than re-wrapping exceptions on the way back out to python. Might be better to just explicitly throw that io exception to python directly?

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 don't think leaning on fs.getPath makes sense in general.

The default already takes care of that:

    default Path resolve(String other) {
        return resolve(getFileSystem().getPath(other));
    }

and then one better, the implementation may override this to be even better.

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'll do some testing against files w/ bad permissions and see what the experience is like...

} catch (IOException e) {
throw new UncheckedIOException(e);
}
plugins.put(jsPlugin.name(), jsPlugin);
Copy link
Member

Choose a reason for hiding this comment

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

maybe write this after the below IO ex doesn't happen, then merge the two try/catch blocks? We don't anticipate a reasonable state where the first succeeds, the map entry gets create, and the second fails, right?

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 written, writeManifest() depends on plugins. We could modify it to writeManifest(jsPlugin), but that seems a bit ill-structured just to suit.

I'm simplifying the writeManifest a bit, but think the order makes sense as-is.

I don't expect writeManifest to fail.

Copy link
Member

Choose a reason for hiding this comment

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

Should somehow match that perspective in code then? "This can't possibly fail, so we're going to make sure the system is unusable if it somehow does manage to fail". Since you'd like the rest of the impl to be agnostic of exactly which FileSystem impl is in use, it should be expected that an ordinary write could fail for some reason. If we're actually assuming that jimfs is the only option, I'd agree with you here, but ask you make the API hard/impossible to use (even in unit tests) with a different fs.

Copy link
Member Author

Choose a reason for hiding this comment

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

In the current path to registrations, if the registration fails, startup will fail (explicitly made the permissions on a file bad):

2022-10-18T17:50:05.354Z | main                 |  INFO | .d.s.p.PluginRegistration | Registering js plugin: @deephaven/js-plugin-plotly@0.1.0 / io.deephaven.server.plugin.type.JsPluginDistribution@2c413ffc                   
Initiating shutdown due to: Uncaught exception in thread main                                                                                                                                                                    
java.lang.RuntimeException: Error in Python interpreter:                                                                                                                                                                         
Type: <class 'RuntimeError'>                                                                                                                                                                                                     
Value: java.nio.file.AccessDeniedException: /tmp/dh/lib64/python3.10/site-packages/deephaven_plugin_plotly_js/dist/index.js                                                                                                      
        at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:90)                                                                                                                                      
        at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)                                                                                                                                       
        at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:116)                                                                                                                                       
        at java.base/sun.nio.fs.UnixFileSystemProvider.newByteChannel(UnixFileSystemProvider.java:219)                                                                                                                           
        at java.base/java.nio.file.Files.newByteChannel(Files.java:371)                                                                                                                                                          
        at java.base/java.nio.file.Files.newByteChannel(Files.java:422)                                                                                                                                                          
        at java.base/java.nio.file.spi.FileSystemProvider.newInputStream(FileSystemProvider.java:420)                                                                                                                            
        at java.base/java.nio.file.Files.newInputStream(Files.java:156)                                                                                                                                                          
        at java.base/java.nio.file.CopyMoveHelper.copyToForeignTarget(CopyMoveHelper.java:125)                                                                                                                                   
        at java.base/java.nio.file.Files.copy(Files.java:1298)                                                                                                                                                                   
        at io.deephaven.server.plugin.type.JsPluginDistribution$CopyRecursiveVisitor.visitFile(JsPluginDistribution.java:141)                                                                                                    
        at io.deephaven.server.plugin.type.JsPluginDistribution$CopyRecursiveVisitor.visitFile(JsPluginDistribution.java:122)                                                                                                    
        at java.base/java.nio.file.Files.walkFileTree(Files.java:2725)                                                                                                                                                           
        at java.base/java.nio.file.Files.walkFileTree(Files.java:2797)                                                                                                                                                           
        at io.deephaven.server.plugin.type.JsPluginDistribution.copyRecursive(JsPluginDistribution.java:103)                                                                                                                     
        at io.deephaven.server.plugin.type.JsPluginDistribution.copyTo(JsPluginDistribution.java:98)                                                                                                                             
        at io.deephaven.server.jetty.jsplugin.JsPlugins.register(JsPlugins.java:93)                                                                                                                                              
        at io.deephaven.server.plugin.PluginRegistrationVisitor.visit(PluginRegistrationVisitor.java:44)                                                                                                                         
        at io.deephaven.server.plugin.PluginRegistrationVisitor.visit(PluginRegistrationVisitor.java:17)                                                                                                                         
        at io.deephaven.plugin.type.JsPluginBase.walk(JsPluginBase.java:13)                                                                                                                                                      
        at io.deephaven.server.plugin.PluginRegistrationVisitor.register(PluginRegistrationVisitor.java:32)                                                                                                                      
        at io.deephaven.server.plugin.PluginRegistration$Counting.visit(PluginRegistration.java:73)                                                                                                                              
        at io.deephaven.server.plugin.PluginRegistration$Counting.visit(PluginRegistration.java:46)                                                                                                                              
        at io.deephaven.plugin.type.JsPluginBase.walk(JsPluginBase.java:13)                                                                                                                                                      
        at io.deephaven.server.plugin.PluginRegistration$Counting.register(PluginRegistration.java:53)                                                                                                                           
        at io.deephaven.server.plugin.python.CallbackAdapter.registerJsPlugin(CallbackAdapter.java:31)                                                                                                                           
        at org.jpy.PyLib.callAndReturnValue(Native Method)                                                                                                                                                                       
        at org.jpy.PyProxyHandler.invoke(PyProxyHandler.java:120)                                                                                                                                                                
        at io.deephaven.server.plugin.python.$Proxy5.initialize_all_and_register_into(Unknown Source)                                                                                                                            
        at io.deephaven.server.plugin.python.PythonPluginsRegistration.registerInto(PythonPluginsRegistration.java:33)                                                                                                           
        at io.deephaven.server.plugin.PluginRegistration.registerAll(PluginRegistration.java:41)                                                                                                                                 
        at io.deephaven.server.runner.DeephavenApiServer.run(DeephavenApiServer.java:136)
        at io.deephaven.server.jetty.JettyMain.main(JettyMain.java:25)


@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
Files.copy(dir, dst.resolve(src.relativize(dir).toString()));
Copy link
Member

Choose a reason for hiding this comment

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

why toString?

Copy link
Member Author

@devinrsmith devinrsmith Oct 17, 2022

Choose a reason for hiding this comment

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

Necessary b/c path.resolve(path) requires that both paths are from the same FS. I'll add a note.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, I hit this once before, and I thought there was a kinder approach... I'll reply back if I find it.

Copy link
Member

Choose a reason for hiding this comment

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

Found it - I explicitly tested their roots, and didn't relativize if they were on a different fs - but that handle distinct filesystems of the same type, but different instances.

It does seem odd, given how Paths.get(..) is implemented, that relative Path instances aren't suitable for use on other filesystems.

copyRecursive(distributionDir, destination);
}

private static void copyRecursive(Path src, Path dst) throws IOException {
Copy link
Member

Choose a reason for hiding this comment

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

we have commons-io elsewhere on the classpath, which has a handy FileUtils.copyDirectory that handles recursion, soft links in a oneliner - not sure if it handles all edge cases the same way, or if it can deal with jimfs, but you likely do want to handle soft links, and may want to address metadata like last-modified.

Copy link
Member Author

Choose a reason for hiding this comment

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

The javadocs for copy say that it follows symlinks - if you don't want that behavior, we can use NOFOLLOW_LINKS.

I'll add in COPY_ATTRIBUTES.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I'm mixing read and write - and if jimfs isn't the only possible fs impl, we need to be more careful of what could happen on writes, guard against files (or directories) already existing, not being empty, etc. Or recursively delete ahead of time?

Copy link
Member Author

Choose a reason for hiding this comment

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

ATM, we are guarded against overwriting since we can't register the same plugin name multiple times.

Copy link
Member

Choose a reason for hiding this comment

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

Guarded by implementation only - should probably have an explicit check for it, since the impl will happen to fail, as opposed to this not being a real use case (and indeed this is a real use case, we do intend to support it).

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 Q.

A user attempting to runtime-register a plugin will see it succeed. But if they try to update it and re-register it, it will fail, and not with a "this isn't support" error, instead they'll get a "IOException: file exists" error, probably with the "absolute" path within the zip (easily confused for their own path). No amount of trying to delete their own file so that it can be created will make sense for them. Instead, if it doesn't work, and it doesn't work by design, we should give them a coherent error around that, rather than just getting lucky that it fails, and leaving them to figure out why on their own.

Copy link
Member Author

Choose a reason for hiding this comment

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

Well, it's also guarded by what we expose - io.deephaven.plugin.Registration.Callback is not presented to the user right now in any sort of static or easily fetch-able context. That's really what #2816 is about. So there isn't currently a way to "runtime-register" or "re-register"

Copy link
Member

Choose a reason for hiding this comment

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

Yep, no arguing that the api isn't complete to allow all possible things, but that we could provide good errors instead of bad ones for features we deliberately don't support instead of accidentally don't support. We explicitly don't support re-registering or updating plugins after they change, we can call that out with an illegal state/arg ex, instead of a "sorry that file exists" io error. We have the information to do, etc. An error when writing this zip is almost certainly "everything is broken" or "you did a thing that we don't support".

For example, without testing it, it looks like while we explicitly support plugins with a / in their name, if you attempt to register a plugin named foo which already has a file at bar/main.js and foo/bar with a file at main.js, there will be an IO error, rather than actually mapping these to different locations, or just saying "sorry some other plugin has already claimed that file name".

* @return the servlet holder
*/
public ServletHolder servletHolder(String name) {
final ServletHolder jsPlugins = new ServletHolder(name, DefaultServlet.class);
Copy link
Member

Choose a reason for hiding this comment

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

probably should explicitly handle caching, either here or in the existing caching setup, esp since that plotly plugin is not small

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 what reasonable cache values are - I'll see if we are inheriting any cache values from jetty; and at a minimum, will add in etag support.

Copy link
Member

Choose a reason for hiding this comment

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

The context itself has etags enabled, but I am not at all certain that is inherited by a sub-DefaultServlet. There are filters for caching, but they are explicit for files that include their hash in their name. Both of these are in JettyBackedGrpcServer.

Copy link
Member Author

Choose a reason for hiding this comment

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

Looks like we do inherit the defaults for etag. I'll add an explicit filter for NoCache.

Copy link
Member

Choose a reason for hiding this comment

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

Idle thought on caching: at least for main, we could rename the file when copying, and report the renamed file via the main value in the json. The renamed string could include a hash in it, so that the url necessarily changes when the file changes, so the file can always be cached.

Same applies for the full plugin by naming the directory of the plugin with the hash of the contents, but the directory walking has to be deterministic.


private void writeManifest() throws IOException {
// note: would this be better as a custom servlet instead? (this current way is certainly easy...)
try (final OutputStream out = new BufferedOutputStream(Files.newOutputStream(path.resolve("manifest.json")))) {
Copy link
Member

Choose a reason for hiding this comment

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

buffering to an in-memory fs?

i ask because its going to be fully read into memory either way, and afaict never GC'd - and while the other files aren't going to be very small, this one is likely to be no bigger than a few kb?

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 taking the FS-agnostic approach here - mentioned earlier, but this was originally written for local temporary filesystem. Even though it's not technically necessary when writing to an in-memory fs, I think it's still best practice to buffer like this.

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense, I added notes in a few other places where we should be truly agnostic to follow this attitude.

@devinrsmith devinrsmith requested a review from niloc132 October 18, 2022 17:55
public static JsPlugins create() throws IOException {
final Path tempDir = Files.createTempDirectory(JsPlugins.class.getName());
tempDir.toFile().deleteOnExit();
final Path fsZip = tempDir.resolve("fs.zip");
Copy link
Member

Choose a reason for hiding this comment

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

more specific name, so the ownership of this file is obvious? deephaven-js-plugins.zip?

Copy link
Member Author

Choose a reason for hiding this comment

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

done

fsZip.toFile().deleteOnExit();
// Note, the URI needs explicitly be parseable as a directory URL ending in "!/", a requirement of the jetty
// resource creation implementation, see
// org.eclipse.jetty.util.resource.Resource.newResource(java.lang.String, boolean)
Copy link
Member

Choose a reason for hiding this comment

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

probably should re/move this comment, code, since it doesn't really apply here any more, but maybe servletHolder()?

Copy link
Member Author

Choose a reason for hiding this comment

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

done

public void registerJsPlugin(String path, String name, String version, String main) throws IOException {
try {
callback.register(JsPluginDistribution.of(Path.of(path), name, version, main));
} catch (UncheckedIOException e) {
Copy link
Member

Choose a reason for hiding this comment

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

why force the wrap and unwrap, instead of just declaring the IO ex? Maybe this isnt needed now that register() declares IO ex (not sure, since there are several methods named register() that take a plugin arg...)?

Copy link
Member Author

Choose a reason for hiding this comment

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

So, I think the interfaces presented either need to not have checked exceptions, or to throw Exception (as opposed to IOException (io.deephaven.plugin.Registration.Callback#register) given their generic nature.

This explicit unwrap is providing slightly better stacktraces for python when something goes wrong, but isn't technically needed.

Copy link
Member

Choose a reason for hiding this comment

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

given how irritating py+java stack traces are, it might be better to have an explicit "i should be translated with only a message and a frame or three into python" java exception - the example error for permissions is daunting, esp given that java and py stack traces run in the opposite order.

not to mention if the py code that calls register might have been called by a dh app (java), which could have been started by python (dhaal)

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 sympathize w/ the struggles of python + java mixed traces. Do we present java exception stack simplification anywhere else?

I worry about the added complexity and fragility of introspecting errors we "expect" to see, at least if we try to solve this on a case-by-case basis.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, this is more of a "the error should be as clean as possible, since our target audience is not python developers who have to deal with java, but js developers who have to deal with python and java, understanding neither".

What i'm asking/hoping for is to deal with expected errors as part of the "state machine" rather than leaving them to "eh, here's an IO error, figure it out"

copyRecursive(distributionDir, destination);
}

private static void copyRecursive(Path src, Path dst) throws IOException {
Copy link
Member

Choose a reason for hiding this comment

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

Guarded by implementation only - should probably have an explicit check for it, since the impl will happen to fail, as opposed to this not being a real use case (and indeed this is a real use case, we do intend to support it).

Files.walkFileTree(src, new CopyRecursiveVisitor(src, dst));
}

static final class PackageJson {
Copy link
Member

Choose a reason for hiding this comment

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

What I'm saying is that we need more than Javadoc for this - a JS/TS developer shouldn't have to already know how java/py works to be able to figure this out.

private final URI filesystem;
private final List<Map<String, String>> plugins;

private JsPlugins(URI filesystem) {
Copy link
Member

Choose a reason for hiding this comment

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

you were going to add tests for this, others?

Copy link
Member Author

Choose a reason for hiding this comment

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

Given the test construction right now, it's easy for me to test the empty case; and at a minimum, that's testing that we correctly wrote out and read back the manifest.json file from the zip filesystem.

It probably makes sense to eventually add more HTTP tests against io.deephaven.server.jetty.JettyBackedGrpcServer that will check our expectations (etag, caching, etc), and have easier injectability for JsPlugin and other HTTP stuff we might want to test.


@Override
public synchronized void register(JsPlugin jsPlugin) throws IOException {
for (Map<String, String> plugin : plugins) {
Copy link
Member

Choose a reason for hiding this comment

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

This structure confuses me a bit - why a map here, if there will always and only be three sets of key/value pairs? And then, why require walking the list, if the list is effectively a map from name to object (then serialize map.values() to get the actual underlying values without the extra name wrapping)?

Also, if the structure exists only for json's sake and not for any actual processing, why not use the node types that jackson provides instead of making it adapt from generic collections?

Copy link
Member

Choose a reason for hiding this comment

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

it looks like PackageJson is nearly correct for this use case, though perhaps with a name change to be clear that it is about packaging, as opposed to being a subset of the actual package.json specification.

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've created a common object called JsPluginInfo that can be both ser/deser from JSON.

final Path pluginPath = fs.getPath("/", jsPlugin.name());
jsPlugin.copyTo(pluginPath);
plugins.add(Map.of(NAME, jsPlugin.name(), VERSION, jsPlugin.version(), MAIN, jsPlugin.main()));
writeManifest(fs);
Copy link
Member

Choose a reason for hiding this comment

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

to confirm, there is no clean way to not call this N+1 times? once when the instance is created (but only via the factory ctor), and once for each plugin added?

Copy link
Member Author

Choose a reason for hiding this comment

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

If we're just concerned about re-writing the manifest multiple times, we can probably come up with a solution to only write it "once".

More generally though, it's not as easy as writing a visitor that collects all the plugins and then installs them in one shot. The lifecycle concerns around io.deephaven.plugin.type.JsPlugin#copyTo for clean python interop has python opening and closing the distribution directory for consumption py/server/deephaven/server/plugin/register.py:

elif isinstance(plugin, JsPlugin):
    with plugin.distribution_path() as distribution_path:
        self._callback.registerJsPlugin(str(distribution_path), plugin.name, plugin.version, plugin.main)

Our avenues for better batch installing either involve

  1. Adding batch registration interfaces in both python and java, plumbing as appropriate
  2. Having an extra layer of indirection during installation, then batching up results (for example, copying everything to jimfs, and then batch to zipfs).

I'll try and document these concerns a bit in code, and see if there is an easy way to solve for just the manifest.

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 did take a stab at trying to be more efficient wrt the manifest; but it wasn't very elegant and I figured it would be better to try and solve it in a more complete way outside of this PR.

#3007

}

private void writeManifest(FileSystem fs) throws IOException {
final Path manifestPath = fs.getPath("/", MANIFEST_JSON);
Copy link
Member

Choose a reason for hiding this comment

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

using "/" violates the assumption you had expressed in chat, where this was going to be testable on a plain fs, rather than only jimfs or zip?

Copy link
Member Author

Choose a reason for hiding this comment

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

Correct - given that zipfs works on new filesystems and has semantics required closing the filesystem, it didn't mesh cleanly w/ how one might use jimfs or plain fs. (Note: this does not prevent JsPlugin from being local filesystem, or something else; just effects the registration implementation.)

As such, I should probably rename and document this class as working with zip filesystems explicitly. If we find we need other implementations centered aroundjimfs or localfs in the future, we can create a new JsPluginRegistration.

* @return the servlet holder
*/
public ServletHolder servletHolder(String name) {
final ServletHolder jsPlugins = new ServletHolder(name, DefaultServlet.class);
Copy link
Member

Choose a reason for hiding this comment

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

Idle thought on caching: at least for main, we could rename the file when copying, and report the renamed file via the main value in the json. The renamed string could include a hash in it, so that the url necessarily changes when the file changes, so the file can always be cached.

Same applies for the full plugin by naming the directory of the plugin with the hash of the contents, but the directory walking has to be deterministic.

@devinrsmith
Copy link
Member Author

Added #3005

@devinrsmith
Copy link
Member Author

Added #3004

@devinrsmith
Copy link
Member Author

Couple of comments I can't re-comment on (not sure why)

#2908 (comment) : I don't understand Q.

#2908 (comment) : I don't expect JS/TS dev being able to necessarily decipher through this either. I hope the external js plugin documentation will be well suited for them, and then we can have docs for "here's how to convert it to PyPi package or jar for easy inclusion".

@devinrsmith devinrsmith requested a review from niloc132 October 20, 2022 00:23
plugin/build.gradle Outdated Show resolved Hide resolved
copyRecursive(distributionDir, destination);
}

private static void copyRecursive(Path src, Path dst) throws IOException {
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 Q.

A user attempting to runtime-register a plugin will see it succeed. But if they try to update it and re-register it, it will fail, and not with a "this isn't support" error, instead they'll get a "IOException: file exists" error, probably with the "absolute" path within the zip (easily confused for their own path). No amount of trying to delete their own file so that it can be created will make sense for them. Instead, if it doesn't work, and it doesn't work by design, we should give them a coherent error around that, rather than just getting lucky that it fails, and leaving them to figure out why on their own.

public void registerJsPlugin(String path, String name, String version, String main) throws IOException {
try {
callback.register(JsPluginDistribution.of(Path.of(path), name, version, main));
} catch (UncheckedIOException e) {
Copy link
Member

Choose a reason for hiding this comment

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

given how irritating py+java stack traces are, it might be better to have an explicit "i should be translated with only a message and a frame or three into python" java exception - the example error for permissions is daunting, esp given that java and py stack traces run in the opposite order.

not to mention if the py code that calls register might have been called by a dh app (java), which could have been started by python (dhaal)

@devinrsmith devinrsmith requested a review from niloc132 October 24, 2022 22:09
niloc132
niloc132 previously approved these changes Oct 27, 2022
Copy link
Member

@niloc132 niloc132 left a comment

Choose a reason for hiding this comment

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

Approving as-is, but I'm unhappy with the approach of making js/ts devs figure out what a java-exception-in-a-python-error is, instead of actively separating plugin semantics from filesystem semantics and dealing with the legal plugin states.

@devinrsmith
Copy link
Member Author

Closed for now due to #3056

@devinrsmith devinrsmith closed this Dec 1, 2022
@github-actions github-actions bot locked and limited conversation to collaborators Dec 1, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
DocumentationNeeded javascript Pull requests that update Javascript code plug-ins python
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants