Skip to content

Commit 806fb73

Browse files
feat(SIP-39): Websocket sidecar app (apache#11498)
* 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 <[email protected]> * rename streamPrefix config Co-authored-by: David Aaron Suddjian <[email protected]>
1 parent 6a81a79 commit 806fb73

33 files changed

+17198
-12
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: WebSocket server
2+
on:
3+
push:
4+
paths:
5+
- "superset-websocket/**"
6+
pull_request:
7+
paths:
8+
- "superset-websocket/**"
9+
10+
jobs:
11+
app-checks:
12+
if: github.event.pull_request.draft == false
13+
runs-on: ubuntu-20.04
14+
steps:
15+
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
16+
uses: actions/checkout@v2
17+
with:
18+
persist-credentials: false
19+
- name: Install dependencies
20+
working-directory: ./superset-websocket
21+
run: npm install
22+
- name: lint
23+
working-directory: ./superset-websocket
24+
run: npm run lint
25+
- name: prettier
26+
working-directory: ./superset-websocket
27+
run: npm run prettier-check
28+
- name: unit tests
29+
working-directory: ./superset-websocket
30+
run: npm run test
31+
- name: build
32+
working-directory: ./superset-websocket
33+
run: npm run build

superset-websocket/.eslintignore

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
*.min.js
18+
node_modules
19+
dist
20+
coverage

superset-websocket/.eslintrc.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
module.exports = {
20+
root: true,
21+
parser: '@typescript-eslint/parser',
22+
env: {
23+
node: true,
24+
browser: true,
25+
},
26+
plugins: [
27+
'@typescript-eslint',
28+
],
29+
extends: [
30+
'eslint:recommended',
31+
'plugin:@typescript-eslint/recommended',
32+
'prettier',
33+
],
34+
rules: {
35+
"@typescript-eslint/explicit-module-boundary-types": 0,
36+
"@typescript-eslint/no-var-requires": 0,
37+
},
38+
};

superset-websocket/.gitignore

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
config.json
18+
dist
19+
node_modules
20+
*.log

superset-websocket/.nvmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
v14.15.5

superset-websocket/.prettierignore

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
*.min.js
18+
node_modules
19+
dist
20+
coverage
21+
.eslintrc.js
22+
.prettierrc.json
23+
*.md
24+
*.json

superset-websocket/.prettierrc.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"trailingComma": "all",
3+
"singleQuote": true,
4+
"arrowParens": "avoid"
5+
}

superset-websocket/README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
# Superset WebSocket Server
20+
21+
A Node.js WebSocket server for sending async event data to the Superset web application frontend.
22+
23+
## Requirements
24+
25+
- Node.js 12+ (not tested with older versions)
26+
- Redis 5+
27+
28+
To use this feature, Superset needs to be configured to enable global async queries and to use WebSockets as the transport (see below).
29+
30+
## Architecture
31+
32+
This implementation is based on the architecture defined in [SIP-39](https://github.com/apache/superset/issues/9190).
33+
34+
### Streams
35+
36+
Async events are pushed to [Redis Streams](https://redis.io/topics/streams-intro) from the [Superset Flask app](https://github.com/preset-io/superset/blob/master/superset/utils/async_query_manager.py). An event for a particular user is published to two streams: 1) the global event stream that includes events for all users, and 2) a channel/session-specific stream only for the user. This approach provides a good balance of performance (reading off of a single global stream) and fault tolerance (dropped connections can "catch up" by reading from the channel-specific stream).
37+
38+
Note that Redis Stream [consumer groups](https://redis.io/topics/streams-intro#consumer-groups) are not used here due to the fact that each group receives a subset of the data for a stream, and WebSocket clients have a persistent connection to each app instance, requiring access to all data in a stream. Horizontal scaling of the WebSocket app requires having multiple WebSocket servers, each with full access to the Redis Stream data.
39+
40+
### Connection
41+
42+
When a user's browser initially connects to the WebSocket server, it does so over HTTP, which includes the JWT authentication cookie, set by the Flask app, in the request. _Note that due to the cookie-based authentication method, the WebSocket server must be run on the same host as the web application._ The server validates the JWT token by using the shared secret (config: `jwtSecret`), and if valid, proceeds to upgrade the connection to a WebSocket. The user's session-based "channel" ID is contained in the JWT, and serves as the basis for sending received events to the user's connected socket(s).
43+
44+
A user may have multiple WebSocket connections under a single channel (session) ID. This would be the case if the user has multiple browser tabs open, for example. In this scenario, **all events received for a specific channel are sent to all connected sockets**, leaving it to the consumer to decide which events are relevant to the current application context.
45+
46+
### Reconnection
47+
48+
It is expected that a user's WebSocket connection may be dropped or interrupted due to fluctuating network conditions. The Superset frontend code keeps track of the last received async event ID, and attempts to reconnect to the WebSocket server with a `last_id` query parameter in the initial HTTP request. If a connection includes a valid `last_id` value, events that may have already been received and sent unsuccessfully are read from the channel-based Redis Stream and re-sent to the new WebSocket connection. The global event stream flow then assumes responsibility for sending subsequent events to the connected socket(s).
49+
50+
### Connection Management
51+
52+
The server utilizes the standard WebSocket [ping/pong functionality](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#pings_and_pongs_the_heartbeat_of_websockets) to determine if active WebSocket connections are still alive. Active sockets are sent a _ping_ regularly (config: `pingSocketsIntervalMs`), and the internal _sockets_ registry is updated with a timestamp when a _pong_ response is received. If a _pong_ response has not been received before the timeout period (config: `socketResponseTimeoutMs`), the socket is terminated and removed from the internal registry.
53+
54+
In addition to periodic socket connection cleanup, the internal _channels_ registry is regularly "cleaned" (config: `gcChannelsIntervalMs`) to remove stale references and prevent excessive memory consumption over time.
55+
56+
## Install
57+
58+
Install dependencies:
59+
```
60+
npm install
61+
```
62+
63+
## WebSocket Server Configuration
64+
65+
Copy `config.example.json` to `config.json` and adjust the values for your environment.
66+
67+
## Superset Configuration
68+
69+
Configure the Superset Flask app to enable global async queries (in `superset_config.py`):
70+
71+
Enable the `GLOBAL_ASYNC_QUERIES` feature flag:
72+
```
73+
"GLOBAL_ASYNC_QUERIES": True
74+
```
75+
76+
Configure the following Superset values:
77+
```
78+
GLOBAL_ASYNC_QUERIES_TRANSPORT = "ws"
79+
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = "ws://<host>:<port>/"
80+
```
81+
82+
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.
83+
84+
The following config values must contain the same values in both the Flask app config and `config.json`:
85+
```
86+
GLOBAL_ASYNC_QUERIES_REDIS_CONFIG
87+
GLOBAL_ASYNC_QUERIES_REDIS_STREAM_PREFIX
88+
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME
89+
GLOBAL_ASYNC_QUERIES_JWT_SECRET
90+
```
91+
92+
More info on Superset configuration values for async queries: https://github.com/apache/superset/blob/master/CONTRIBUTING.md#async-chart-queries
93+
94+
## Running
95+
96+
Running locally via dev server:
97+
```
98+
npm run dev-server
99+
```
100+
101+
Running in production:
102+
```
103+
npm run build && npm start
104+
```
105+
106+
*TODO: containerization*
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"port": 8080,
3+
"logLevel": "info",
4+
"logToFile": false,
5+
"logFilename": "app.log",
6+
"redis": {
7+
"port": 6379,
8+
"host": "127.0.0.1",
9+
"password": "",
10+
"db": 0,
11+
"ssl": false
12+
},
13+
"redisStreamPrefix": "async-events-",
14+
"jwtSecret": "CHANGE-ME",
15+
"jwtCookieName": "async-token"
16+
}

superset-websocket/config.test.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"redis": {
3+
"port": 6379,
4+
"host": "127.0.0.1",
5+
"password": "",
6+
"db": 10,
7+
"ssl": false
8+
},
9+
"redisStreamPrefix": "test-async-events-",
10+
"jwtSecret": "test123-test123-test123-test123-test123-test123-test123",
11+
"jwtCookieName": "test-async-token"
12+
}

0 commit comments

Comments
 (0)