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

feat(SIP-39): Websocket sidecar app #11498

Merged
merged 27 commits into from
Apr 8, 2021

Conversation

robdiciuccio
Copy link
Member

@robdiciuccio robdiciuccio commented Oct 30, 2020

SUMMARY

Node.js WebSocket server application for SIP-39 pub/sub architecture. This PR contains:

  • The main WebSocket application (superset-websocket/src/index.ts)
    • Consumes Redis streams for event data, pushing to connected WebSocket clients
    • Utilizes signed JWT for authentication from Superset frontend
    • Leverages WebSocket ping/pong for connection management
    • Configurable connection timeout and garbage collection settings
  • A new Superset Flask app config key: GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL
  • An Express-based client application for testing and development only (superset-websocket/utils/client-ws-app/)
  • CI actions for running automated unit tests

Corresponding Superset web app changes are in the following PRs: #11499, #13696

To be added in future PRs:

  • Integration tests
  • Containerization

TEST PLAN

  1. CI runs unit tests and build checks
  2. Run the WS server locally and configure Superset to use the websocket transport for async queries

ADDITIONAL INFORMATION

  • Has associated issue: [SIP-39] Global Async Query Support #9190
  • Changes UI
  • Requires DB Migration.
  • Confirm DB Migration upgrade and downgrade tested.
  • Introduces new feature or API
  • Removes existing feature or API

@codecov-io
Copy link

codecov-io commented Oct 30, 2020

Codecov Report

Merging #11498 (fb00d15) into master (a0a00e6) will increase coverage by 0.12%.
The diff coverage is 90.00%.

❗ Current head fb00d15 differs from pull request most recent head 3af092f. Consider uploading reports for the commit 3af092f to get more accurate results
Impacted file tree graph

@@            Coverage Diff             @@
##           master   #11498      +/-   ##
==========================================
+ Coverage   78.35%   78.47%   +0.12%     
==========================================
  Files         934      937       +3     
  Lines       47350    47439      +89     
  Branches     5941     5976      +35     
==========================================
+ Hits        37102    37229     +127     
+ Misses      10104    10067      -37     
+ Partials      144      143       -1     
Flag Coverage Δ
cypress 56.03% <73.80%> (-0.01%) ⬇️
hive ?
mysql 80.56% <77.77%> (-0.01%) ⬇️
postgres 80.55% <77.77%> (-0.05%) ⬇️
presto 80.29% <77.77%> (-0.01%) ⬇️
python 80.82% <77.77%> (-0.33%) ⬇️
sqlite 80.16% <77.77%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
...et-frontend/src/SqlLab/components/TableElement.jsx 88.60% <ø> (+0.23%) ⬆️
...end/src/common/components/DropdownButton/index.tsx 24.00% <0.00%> (ø)
.../components/CrossFilterScopingModal/utils/index.ts 100.00% <ø> (ø)
...dashboard/components/SliceHeaderControls/index.jsx 77.89% <ø> (ø)
...perset-frontend/src/datasource/DatasourceModal.tsx 72.58% <ø> (ø)
...components/controls/FilterBoxItemControl/index.jsx 73.58% <ø> (ø)
...onents/controls/FilterControl/AdhocFilter/index.js 96.92% <ø> (ø)
...ontrols/FilterControl/AdhocFilterControl/index.jsx 60.00% <ø> (ø)
...ols/FilterControl/AdhocFilterEditPopover/index.jsx 66.00% <ø> (ø)
...l/AdhocFilterEditPopoverSimpleTabContent/index.jsx 77.48% <ø> (ø)
... and 53 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update a0a00e6...3af092f. Read the comment docs.

@craig-rueda
Copy link
Member

Did you consider Typescript here?

@robdiciuccio
Copy link
Member Author

@craig-rueda yes, the plan is to migrate this to TypeScript (added as a TODO item)

@ktmud
Copy link
Member

ktmud commented Oct 30, 2020

If you are going to use TypeScript, I'd recommend look into integrating esbuild in the workflow. It's much faster than Babel and TSC and seems good enough for node.js environment.

superset-websocket/index.js Outdated Show resolved Hide resolved
}

const startServer = process.argv[2] === 'start';
const configFile = environment === 'test' ? '../config.test.json' : '../config.json';
Copy link
Member

Choose a reason for hiding this comment

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

Can we add a CLI argument for the config file?

* Module dependencies.
*/

var app = require('../app');
Copy link
Member

Choose a reason for hiding this comment

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

What does this do? Is it the main entrypoint?

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 all boilerplate Express code. Not used in production app.

app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

app.use(logger('dev'));
Copy link
Member

Choose a reason for hiding this comment

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

what's dev in this context? Is that the logger level?

Copy link
Member Author

Choose a reason for hiding this comment

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

Boilerplate Express app code. Not part of the production app.

app.use(function(err, req, res) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
Copy link
Member

Choose a reason for hiding this comment

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

Can we leverage settings from the config file referenced above? I'd advise using one or the other (env vars of config file)

Copy link
Member Author

Choose a reason for hiding this comment

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

Test utility app only, all boilerplate.

Copy link
Member

@craig-rueda craig-rueda left a comment

Choose a reason for hiding this comment

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

Overall, looks good. I'm pretty sure it works, as you've been working on this for some time now... I just think a few spots could use some more code comments to guide others when digging in.

Copy link
Contributor

@benjreinhart benjreinhart left a comment

Choose a reason for hiding this comment

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

I think the core logic is looking good, but had a few minor comments.

Also:

  1. Nitpick but looks like the indentation is inconsistent across files with both two and four spaces being used. Not a huge deal but might be nice to add prettier and forget about these types of things.
  2. Do we normally check in auxiliary code into the core repository? In this case, I'm referring the the load testing application. Would it make sense to have that as a separate repo outside of the main Superset repo?

@@ -0,0 +1,18 @@
#
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this ignore node_modules ?

Copy link
Member Author

Choose a reason for hiding this comment

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

node_modules is ignored in the top-level .gitignore, but I'll make it explicit.

GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = "ws://<host>:<port>/"
```

Note that the WebSocket server must be run on the same hostname (different port) for cookies to be shared between the Flask app and the WebSocket server.
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the JWT the only thing needed from the cookies? If so, have we considered passing that value explicitly? Seems like if we could do that, we could remove this assumption/constraint.

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 could pass it explicitly, though we'd have to make the cookie readable by JS (currently it's httponly), so there is a bit of a tradeoff here.

Copy link
Member

Choose a reason for hiding this comment

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

Keeping the cookie httponly is a good move!

"uuid": "^8.3.2",
"ws": "^7.4.2"
},
"devDependencies": {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not important now, but I think it would be good to eventually add prettier to this application.

try {
socketInstance.ws.send(strData);
} catch(err) {
console.debug('Error sending to socket', err);
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Is this error indicative of a real problem? If so, should this be console.error?

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 can happen for a number of reasons, including a disconnected socket. I believe this was a console.error and was downgraded to a debug due to noise.


export const sendToChannel = (channel: string, value: EventValue): void => {
const strData = JSON.stringify(value);
if(!channels[channel]) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the behavior when we pull an event off the redis stream but for one reason or another we have no channel to send it to? Is that event lost? Or does the event stay in the redis stream and we expect the client to have the right state to connect to the event server and pull it in the future?

Copy link
Member Author

Choose a reason for hiding this comment

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

Events are persisted in the Redis streams until the (configurable) maximum number of items is reached. For the global stream, yes, the event is dropped if there is no available channel/socket connected. The reconnection logic then handles replaying any events that were lost while disconnected.


export const subscribeToGlobalStream = async (stream: string, listener: ListenerFunction) => {
/*eslint no-constant-condition: ["error", { "checkLoops": false }]*/
while (true) {
Copy link
Contributor

Choose a reason for hiding this comment

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

It's probably premature to worry about performance here and I think this is likely not an issue, but I'm curious if there are any potential concerns you foresee with an infinite loop calling redis? It looks like the BLOCK option should prevent too many requests in too short a time since if no events are available it'll wait, but not sure what would happen if one or two events regularly trickle in within short timeframes?

Copy link
Member Author

Choose a reason for hiding this comment

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

When events are received, it returns immediately. More info here: https://redis.io/commands/xread#blocking-for-data

When the BLOCK command is passed, but there is data to return at least in one of the streams passed, the command is executed synchronously exactly like if the BLOCK option would be missing.

Copy link
Member

Choose a reason for hiding this comment

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

"Block" could be a confusing term here. In typical Node parlance, "block" means synchronous code, which this is not. It might be helpful to add a comment clarifying what "blocking" means in this context.

Copy link
Contributor

@benjreinhart benjreinhart left a comment

Choose a reason for hiding this comment

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

:shipit:

};

type ListenerFunction = (results: StreamResult[]) => void;
interface EventValue {
Copy link
Contributor

Choose a reason for hiding this comment

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

If I'm reading this correctly, it looks like under the polling implementation, the response is in the format:

type PollingResponse = {
  result: EventValue;
}

Whereas the response here is simply:

type WebSocketResponse = EventValue;

I'm not opinionated either way, but I think we should consider having a standard structure

Copy link
Contributor

@benjreinhart benjreinhart Apr 5, 2021

Choose a reason for hiding this comment

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

Also, it looks like the initial call to /api/v1/chart/data returns EventValue without the { result: ... } wrapper, so maybe we should change the polling response as that seems to be the outlier?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ahh, nevermind, I missed that the polling response returns a list given there could be multiple events

Copy link
Member

@suddjian suddjian left a comment

Choose a reason for hiding this comment

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

Left a few minor-medium suggestions. Looks great, excited for this feature (and to get a node server in Superset! 😁)

GLOBAL_ASYNC_QUERIES_REDIS_CONFIG
GLOBAL_ASYNC_QUERIES_REDIS_STREAM_PREFIX
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME
GLOBAL_ASYNC_QUERIES_JWT_SECRET
Copy link
Member

Choose a reason for hiding this comment

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

idea, non-blocking: If these are not configured correctly, can we have the service detect it and provide hints to help devs/admins get it working?

"db": 10,
"ssl": false
},
"streamPrefix": "test-async-events-",
Copy link
Member

Choose a reason for hiding this comment

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

change request: Can we make this more specific? Not clear what a streamPrefix is used for.

"lint": "eslint . --ext .js,.jsx,.ts,.tsx && npm run type",
"dev-server": "ts-node src/index.ts start",
"build": "tsc",
"prettier-check": "prettier --check .",
Copy link
Member

Choose a reason for hiding this comment

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

change request: Could this be named format-check instead?

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 was named prettier-check for consistency with superset-frontend.

Copy link
Member Author

Choose a reason for hiding this comment

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

Happy to rename, but curious what people think about keeping these kinds of things consistent.

@@ -1124,6 +1125,7 @@ class CeleryConfig: # pylint: disable=too-few-public-methods
GLOBAL_ASYNC_QUERIES_JWT_SECRET = "test-secret-change-me"
GLOBAL_ASYNC_QUERIES_TRANSPORT = "polling"
GLOBAL_ASYNC_QUERIES_POLLING_DELAY = 500
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = "ws://127.0.0.1:8080/"
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 potential for the websocket server to be used for purposes other than async queries in the future? If so, this config should be given a more general name.

Copy link
Member Author

Choose a reason for hiding this comment

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

Possibly, yes, but no immediate use cases have been defined.

reset_token = True
elif user_id != session["async_user_id"]:
reset_token = True
reset_token = (
Copy link
Member

Choose a reason for hiding this comment

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

Small change but I like how much easier to read this is 🎉

};

type ListenerFunction = (results: StreamResult[]) => void;
interface EventValue {
Copy link
Member

Choose a reason for hiding this comment

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

If this server might eventually deliver multiple kinds of events, it would be desirable to separate the id and channel_id, which are used by the server, from the schema for the other fields, which are specific to the async query event type.

So, an interface like:

interface EventValue<T extends object> {
id: string;
channel_id: string;
data: T; // everything else
}

Copy link
Member Author

Choose a reason for hiding this comment

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

Unclear what future use cases might be, but the existing properties are fairly generic, IMO.

Copy link
Member

@suddjian suddjian Apr 6, 2021

Choose a reason for hiding this comment

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

Future use cases could include a wide variety of things, some that I can think of:

  • real-time updates to entities such as dashboards
  • comments or other user activity
  • notifications
  • streaming query results
  • any other real-time state change

The schema of different types of messages seems quite likely to vary from that of async queries, imo.

Copy link
Member Author

Choose a reason for hiding this comment

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

Agree that there are many potential use cases, but my point is that these should not hold up this PR. The interface in question is already in use, introduced in 1.0, and would require some additional planning and semver gymnastics to refactor.

Copy link
Member

Choose a reason for hiding this comment

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

Agreed that this and the config comment seem somewhat out of scope for this PR, just wanted to raise it as it seems like the kind of thing that might be significantly more difficult to change later.


export const subscribeToGlobalStream = async (stream: string, listener: ListenerFunction) => {
/*eslint no-constant-condition: ["error", { "checkLoops": false }]*/
while (true) {
Copy link
Member

Choose a reason for hiding this comment

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

"Block" could be a confusing term here. In typical Node parlance, "block" means synchronous code, which this is not. It might be helpful to add a comment clarifying what "blocking" means in this context.

* Reads a range of events from a channel-specific Redis event stream.
* Invoked in the client re-connection flow.
*/
export const fetchRangeFromStream = async ({
Copy link
Member

Choose a reason for hiding this comment

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

Should this return the reply in a promise instead of using a listener?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, good call. I'll look at refactoring.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thinking about this more, there's an argument to be made for keeping this consistent with the interface for subscribeToGlobalStream.

superset-websocket/src/index.ts Outdated Show resolved Hide resolved
superset-websocket/tsconfig.json Outdated Show resolved Hide resolved
Rob DiCiuccio and others added 2 commits April 6, 2021 10:31
Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>
Copy link
Member

@villebro villebro left a comment

Choose a reason for hiding this comment

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

Nothing to add to the already extensive comments from others, so I focused on testing this and I must say this is really exciting stuff; chart loading times seem to be way down and it almost feels like one of the main bottlenecks right now for pre-cached data is the slow rendering times of the NVD3 charts 🎉 Tested the following:

  • install based on documentation - very straight forward, had it up and running in a few mins ✅
  • regular dashboard loading, comparing non-DAQ, polling DAQ and WS DAQ - WS DAQ loaded - WS DAQ by far the quickest to load ✅
  • tested rendering of native filters (both filter tab and filter config modal) ✅
  • triggering chart queries with native filters and cross filters ✅

const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const config = require('../../../config.json');
Copy link
Member

Choose a reason for hiding this comment

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

nit:

Suggested change
const config = require('../../../config.json');
const config = require('/config.json');

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 this will actually work, node might try to get config.json from the root dir.

Copy link
Member

Choose a reason for hiding this comment

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

I was unsure, too, but it did seem to work when I tested 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.

Doesn't seem to work for me:

Error: Cannot find module '/config.json'

@robdiciuccio robdiciuccio merged commit 806fb73 into apache:master Apr 8, 2021
@robdiciuccio robdiciuccio deleted the rd/async-websocket-app branch April 8, 2021 18:12
allanco91 pushed a commit to allanco91/superset that referenced this pull request May 21, 2021
* WIP node.js websocket app

* Load testing

* Multi-stream publish with blocking reads

* Use JWT for auth and channel ID

* Update ws jwt cookie name

* Typescript

* Frontend WebSocket transport support

* ws server ping/pong and GC logic

* ws server unit tests

* GC interval config, debug logging

* Cleanup JWT cookie logic

* Refactor asyncEvents.ts to support non-Redux use cases

* Update tests for refactored asyncEvents

* Add eslint, write READMEs, reorg files

* CI workflow

* Moar Apache license headers

* pylint found something

* adjust GH actions workflow

* Improve documentation & comments

* Prettier

* Add configurable logging via Winston

* Add SSL support for Redis connections

* Fix incompatible logger statements

* Apply suggestions from code review

Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>

* rename streamPrefix config

Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>
QAlexBall pushed a commit to QAlexBall/superset that referenced this pull request Dec 29, 2021
* WIP node.js websocket app

* Load testing

* Multi-stream publish with blocking reads

* Use JWT for auth and channel ID

* Update ws jwt cookie name

* Typescript

* Frontend WebSocket transport support

* ws server ping/pong and GC logic

* ws server unit tests

* GC interval config, debug logging

* Cleanup JWT cookie logic

* Refactor asyncEvents.ts to support non-Redux use cases

* Update tests for refactored asyncEvents

* Add eslint, write READMEs, reorg files

* CI workflow

* Moar Apache license headers

* pylint found something

* adjust GH actions workflow

* Improve documentation & comments

* Prettier

* Add configurable logging via Winston

* Add SSL support for Redis connections

* Fix incompatible logger statements

* Apply suggestions from code review

Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>

* rename streamPrefix config

Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>
@mistercrunch mistercrunch added 🏷️ bot A label used by `supersetbot` to keep track of which PR where auto-tagged with release labels 🚢 1.2.0 labels Mar 12, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🏷️ bot A label used by `supersetbot` to keep track of which PR where auto-tagged with release labels size/XL v1.2 🚢 1.2.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants