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

Arbitrary node labeling #613

Merged
merged 14 commits into from
Aug 1, 2018
Merged

Arbitrary node labeling #613

merged 14 commits into from
Aug 1, 2018

Conversation

bryfox
Copy link
Contributor

@bryfox bryfox commented Jul 20, 2018

WIP for initial discussion.

This prototype allows a protocol author to write JavaScript to generate labels for nodes, and a protocol user to run the code without needing to fully trust it. See #610; #472.

Implementation

The user code is written in vanilla JS/ES and packaged with the protocol as node-label-worker.js. When the protocol is loaded (e.g., when an interview starts), the contents of that script are read and made available from a local URL in redux state.

The script is executed by a Web Worker, which is intialized once and can be re-messaged as props change.

The Web Worker provides a sandboxed environment — it cannot access the DOM, the scope of the React app, nor anything that electron provides. A worker communicates through the app through serializable messages.

I've restricted the app's CSP a bit here to prevent cross-origin requests as well. The CSP is respected as long as the data is read in & served from the local (blob) URL. This restricts the script beyond the standard webworker, but removes the risk of data exfiltration, and encourages the worker to be lightweight.

From a script author's point of view, they are implementing the following mapping function (actual inputs may change):

f(node, network, externalData) => label

Behavior

The development protocol contains some examples that demonstrate some use cases from #610. The uncommented mapping function will append an emoji to the node name based on the node's close_friend property.

If a protocol does not include the custom labeling script, then we fall back immediately to the original label function.

If a web worker errors, then the node falls back to the original label function.

Assumptions / Design

  • All inputs to the mapping function are contained in (or can be derived from) redux state
  • Labels are intended to be stable across prompts, but the script author shares responsibility for this
    • Here, I've made the node responsible for its own display, which is easier than passing label & label function props through its ancestors. (It's already responsible for its color, so unless there's a use case I'm missing, I think it this component should be a container and own 'getNodeLabelFunction'.)
  • The mapping function should be asynchronous
  • The output (label) may change at any time the inputs change, including within a prompt
  • Script author will ensure compatibility with protocol.json — e.g., no need to pass variable registry. (Changes across versions is the main gotcha?)
  • There may be a few other use cases that call for using a similar approach, but not more

I initially prototyped this by operating on an array of nodes at once, but that has some drawbacks:

  • Nodes are recreated when dragging between lists/buckets/etc.; we only need the new label for that instance
  • It's harder (or at least more verbose) to write a bug-free mapping function
  • There's no great way to memoize this anyway; we'd have to deeply compare all inputs, which is likely too slow

Known issues

  • Slowness initializing, when you refresh the page while on an interface that uses this. This manifests as a slight delay in rendering the labels. The worker initialization itself is fast; possibly this is from reading the file? I'm not sure why it's slower than loading the protocol itself; I thought they were happening in parallel. Normal interview flows (without app reloading) seem fine, but I imagine this is solvable.
    • Update: see comments below.
  • externalData could be big! We may not want to (or be able to) push this through worker messages. We could make this optional (via protocol.json), and/or make it a separate message, which would allow a single request to cache the external data (which shouldn't change).
  • The roster-based name generators assume that if two nodes have the same props, they're identical to the user, and should be treated as dupes. That's no longer the case; however, I think UUIDs (Components that consume external data as a network must generate UUIDs for nodes #415) will solve this.

There are a few risks from a misbehaving script:

  • DOS: the worker runs in a different thread, but can clog the app's event loop. We could mitigate by debouncing, or detecting and dropping.
  • Defacement: we'll display whatever string is returned. (JSX should prevent any more serious problems.)

@wwqrd
Copy link
Contributor

wwqrd commented Jul 23, 2018

This is an exciting feature!

  • Could we make the worker generic and use it for other extensions? i.e.

    webWorker.postMessage({
      type: 'NODE_LABEL',
      // arbitrary properties:
      node,
      network,
      externalData,
    });
    
  • Considering the following assumption, could label generation be attached it to redux thunks / rxjs and be purely action driven?

    All inputs to the mapping function are contained in (or can be derived from) redux state

@bryfox
Copy link
Contributor Author

bryfox commented Jul 23, 2018

Could we make the worker generic and use it for other extensions?

It's certainly possible. Downsides that I see:

The worker's owner wouldn't know which extensions were implemented until the last minute. Hypothetical example: NC supports a skip logic worker, and a protocol includes that functionality on the fifth stage, but does not use custom node labeling. All node-consuming interfaces see that a worker is implemented, so they ask for custom labels needlessly. I suppose we could cache that information somewhere.

We may also want to introduce other message types (for example, to negotiate what external data is passed in), so having a single-purpose worker seems cleaner to me.

However, I do think we should make the plumbing reusable/generic, which I haven't done in the prototype here. e.g., we shouldn't end up with a separate 'loadWorker' epic for each worker type. Would that take care of the issue?

could label generation be attached it to redux thunks / rxjs and be purely action driven?

Maybe! I struggled with this; here are the reasons I went with what you see.

  1. Custom labels feel more like derived state to me. If this was synchronous, I'd want to put it in a selector and call it done. With async dispatch, we'd end up modifying state in the reducers in response to the second actions (correct?). If we had a clearer deliniation between external data and app-generated values, then updating state would feel a little better to me.

  2. State vs. display. Nodes are represented in the network and in external data. An interface may need 3k nodes from external data, but it might not actually display any/all of them. (Example: the roster search generator.) We don't want to do the work of generating labels that won't be displayed, and trying to convey UI state to the store feels backwards. Is there a clean way around that?

Design around the single-node map function
- Add a workerAgent to multiplex messages
- Make the user functions ignorant of Worker boilerplate
- Remove externalData for now; see #413
- Formally make Node a container; avoid sending label props
  through ancestors
Chrome/Android supports worker-src as of v59; Android 8.0 uses v58.

Legacy client-src didn't seem to work.
@bryfox bryfox force-pushed the feature/arbitrary-node-labeling branch from b3b762e to 0a11d1a Compare July 26, 2018 22:25
@bryfox
Copy link
Contributor Author

bryfox commented Jul 26, 2018

I've rebased onto master & pushed the following updates:

  • Redux state, epic & loader can now generically support multiple workers
  • A single worker is used for all labeling; a workerAgent multiplexes messages for clients
  • To support that, there's some needed boilerplate to track message IDs. I've moved the responsibility of integration to some generic wrapper code.
    • Authors need to follow the convention of function name (see comments), but only write a pure mapping function; they don't have to worry about web workers
    • Async user code is explicitly supported; we may want disk I/O at some point
  • I removed externalData for now; Refactor externalData in protocol #413 may determine that doesn't live in redux state anyway
  • I formally made Node a container; it was already connected, and this simplifies all the parent components
  • Android is working now

The demo labeler has changed to exercise non-node prop changes. For testing:

  • Nodes get their 'name' by default
  • A close_friend gets an emoji
  • If a node is included in a friendship link (from first sociogram), it gets a different emoji

@wwqrd
Copy link
Contributor

wwqrd commented Jul 27, 2018

Would it make sense to pass in the registry (i.e. displayVariable) and have this worker always do labeling?

@bryfox
Copy link
Contributor Author

bryfox commented Jul 27, 2018

In that case, I guess we'd have a 'factory' labeler that provided default labeling when the protocol doesn't include a nodeLabel script?

I do like the idea of having a single mechanism to label nodes, but there's enough overhead here (and this operation is common enough) that it feels wrong. Performance, scalability, battery use, etc. are all a little worse. (It's worth noting that node rendering happens more frequently when a worker is used, since any change in the network can cause the label to change.) It's a cost worth paying for flexibility & safety, but it's not the common case. That's just my gut feeling.

Also, one nice property with the current setup is that if the worker errors for any reason, that node will fall back to synchronous labeling. So I don't think we'd be able to lose the existing (synchronous) code path.

But I'm not sure; if you think that's the right approach, I'm happy to implement it.

@bryfox
Copy link
Contributor Author

bryfox commented Jul 27, 2018

I investigated the "Slowness initializing" issue.

Specifically, what I'm seeing upon refreshing a page which renders nodes is a ~250ms delay between the node backgrounds rendering and the labels rendering. (This is only on page load; stage switching, etc. is fine.)

~99% of the latency of setting the worker can be attributed to the readFile call that loads the external script. I'm not sure what else is in the queue, but it resolves that much later than the protocol.json read.

To solve the issue, we could ensure that the external script is loaded before the protocol, so that the user would see the spinner during that time. However, that would delay the loading slightly for all interviews (assuming some don't use external scripts). Further, if the first interface doesn't render nodes (e.g, it has an intro screen), or the app is loaded before handing to an interviewee, then this a non-issue for a typical interview flow. My inclination is to do nothing, for that reason.

@bryfox bryfox changed the title [WIP] Arbitrary node labeling Arbitrary node labeling Jul 27, 2018
@wwqrd
Copy link
Contributor

wwqrd commented Jul 30, 2018

Preloading assets might be a useful feature to have elsewhere in the app, so I think it's at least worth exploring as a separate issue?

@bryfox
Copy link
Contributor Author

bryfox commented Jul 30, 2018

I'm happy to work on it here as well.

What would the other use cases be for preloading assets? In the case above, I can pretty easily preload the scripts before protocol.json, and nothing else needs to change.

If we want to be able to preload a file that's defined inside protocol.json, we might need to have some separate piece of state that the UI can use to know that "everything I need is loaded." Or else have the interface know everything it's looking for in advance. (i.e., show the spinner until both the protocol is loaded and the additional assets are loaded.) Thoughts?

@wwqrd
Copy link
Contributor

wwqrd commented Jul 30, 2018

Some possible other uses might be large datasets or other workers? It's a speculative feature though, so perhaps it should be developed if/when it's needed.

@rebeccamadsen
Copy link
Contributor

If the custom labeling script is present, but returns nothing (and no error), can we fall back to the label function? For example, on the name generator to test input types, nodes have no label at all because nodes created with that form have no name. Or is that just up to the users to write a good script?

@rebeccamadsen
Copy link
Contributor

Also, I can't load the protocols in Chrome; android and electron work well. On starting the app:

websocket.js:6 Refused to connect to 'ws://localhost:3000/sockjs-node/504/w2uyjcy2/websocket' because it violates the following Content Security Policy directive: "connect-src 'self' data: http://localhost:* https://localhost:*"

And then after trying to return to an in progress interview or create a new one:

Uncaught Error: loadProtocol() not supported on this platform
    at :3000/static/js/bundle.js:375494
    at :3000/static/js/bundle.js:373422
    at SwitchMapSubscriber.project (:3000/static/js/bundle.js:361192)
    at SwitchMapSubscriber../node_modules/rxjs/operators/switchMap.js.SwitchMapSubscriber._next (:3000/static/js/bundle.js:301726)
    at SwitchMapSubscriber../node_modules/rxjs/Subscriber.js.Subscriber.next (:3000/static/js/bundle.js:280728)
    at FilterSubscriber../node_modules/rxjs/operators/filter.js.FilterSubscriber._next (:3000/static/js/bundle.js:297233)
    at FilterSubscriber../node_modules/rxjs/Subscriber.js.Subscriber.next (:3000/static/js/bundle.js:280728)
    at Subject../node_modules/rxjs/Subject.js.Subject.next (:3000/static/js/bundle.js:280458)
    at :3000/static/js/bundle.js:268001
    at :3000/static/js/bundle.js:268846
    at :3000/static/js/bundle.js:249040
    at Object.loadFactoryProtocol (:3000/static/js/bundle.js:268933)
    at ProtocolList._this.onClickNewProtocol (:3000/static/js/bundle.js:358361)
    at selectProtocol (:3000/static/js/bundle.js:358413)
    at onClick (:3000/static/js/bundle.js:349920)
    at HTMLUnknownElement.callCallback (:3000/static/js/bundle.js:223182)
    at Object.invokeGuardedCallbackDev (:3000/static/js/bundle.js:223220)
    at Object.invokeGuardedCallback (:3000/static/js/bundle.js:223269)
    at Object.invokeGuardedCallbackAndCatchFirstError (:3000/static/js/bundle.js:223283)
    at executeDispatch (:3000/static/js/bundle.js:223548)
    at executeDispatchesInOrder (:3000/static/js/bundle.js:223570)
    at executeDispatchesAndRelease (:3000/static/js/bundle.js:223668)
    at executeDispatchesAndReleaseTopLevel (:3000/static/js/bundle.js:223679)
    at forEachAccumulated (:3000/static/js/bundle.js:223649)
    at runEventsInBatch (:3000/static/js/bundle.js:223810)
    at runExtractedEventsInBatch (:3000/static/js/bundle.js:223819)
    at handleTopLevel (:3000/static/js/bundle.js:227283)
    at batchedUpdates (:3000/static/js/bundle.js:235619)
    at batchedUpdates (:3000/static/js/bundle.js:225021)
    at dispatchEvent (:3000/static/js/bundle.js:227364)
    at interactiveUpdates (:3000/static/js/bundle.js:235674)
    at interactiveUpdates (:3000/static/js/bundle.js:225040)
    at dispatchInteractiveEvent (:3000/static/js/bundle.js:227341)

@bryfox
Copy link
Contributor Author

bryfox commented Jul 31, 2018

If the custom labeling script is present, but returns nothing (and no error), can we fall back to the label function?

Yes, that makes sense. I'll treat empty responses as errors & document it that way. I assume an empty label is never desired. Right now, it's still possible to get 'undefined', but we could hypothetically catch that with validation, which we couldn't really do with the custom script.

@jthrilly
Copy link
Member

Love this, Bryan. Its a very elegant solution to a problem you identified which I hadn't really considered. Tested on all platforms and devices, and seems to be working well, with the exception of the two issues you know about:

  • Init time - was kinda noticeable on the pixel C, but not elsewhere. It would be nice to resolve this by preloading, but it is something we can enhance later.
  • Chrome - error resulting from the more strict CSP Rebecca found above.

I only really have two concerns: async labels popping in as a potentially sketchy UX, and the scaling performance of the functions for generating labels themselves. On the first, there's not a great deal we can do, except try to minimize label recalculation. Better to keep the solution generic for now, and optimize later.

On the second, this is partly a concern because we make nodes responsible for their own label (like colors), which while I think is the best design choice, results in labels such as "[property] + [globally incrementing counter]" requiring lots of iteration, which might be expensive.

Do you think it might be worth adding some stress-test type scenarios to the dev protocol?

Restore support for browser development (dev-server)
@bryfox
Copy link
Contributor Author

bryfox commented Jul 31, 2018

async labels popping in as a potentially sketchy UX

I think from the app side, we need to be mindful of using keys appropriately so that we're not using the same component to render different data. I fixed the one place I saw. Otherwise, the script author needs to be mindful of consistency and [reasonable] performance.

scaling performance of the functions for generating labels themselves

To evaluate scalability, I used the 'mock nodes' feature, and was able to run this with 100-200 nodes without much issue. There are some places we can minimize unnecessary re-renders, which I'm working on elsewhere; the custom labeler didn't seem to be a large issue. I don't think we're expecting more than that in an app-generated network, but please let me know if that's wrong.

External data is another possible source of scalability problems (including with the 'global incrementing counter' case). I removed externalData for now (partly because of #413). In the abstract, I would expect to support a separate messaging & caching layer for external data. There's a single worker for all nodes, so a script author could fetch that data once, cache any counters as needed, and then run other calculations as is happening now. Once that's implemented, it would make sense to add a reference a large external data source from the dev protocol for testing.

@bryfox
Copy link
Contributor Author

bryfox commented Jul 31, 2018

I've pushed the following updates:

  • Fix app loading in Chrome (loadProtocol error)
  • Fix live reloading in Chrome (CSP settings)
  • Treat empty labels as errors & fall back to static getLabel function

Fall back to the static getLabel function in this case.
@rebeccamadsen
Copy link
Contributor

Updates look good to me, although last_name is missing from the variable registry when I try to export.

@bryfox
Copy link
Contributor Author

bryfox commented Jul 31, 2018

last_name is fixed — thanks.

@bryfox
Copy link
Contributor Author

bryfox commented Aug 1, 2018

I opened #621 to cover preloading as a later enhancement.

@bryfox bryfox merged commit f488179 into master Aug 1, 2018
@bryfox bryfox deleted the feature/arbitrary-node-labeling branch August 1, 2018 21:04
This was referenced Aug 3, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

4 participants