feat(SIP-39): Websocket sidecar app (#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 <1858430+suddjian@users.noreply.github.com>

* rename streamPrefix config

Co-authored-by: David Aaron Suddjian <1858430+suddjian@users.noreply.github.com>
This commit is contained in:
Rob DiCiuccio 2021-04-08 11:12:03 -07:00 committed by GitHub
parent 6a81a7961c
commit 806fb73d25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 17198 additions and 12 deletions

View File

@ -0,0 +1,33 @@
name: WebSocket server
on:
push:
paths:
- "superset-websocket/**"
pull_request:
paths:
- "superset-websocket/**"
jobs:
app-checks:
if: github.event.pull_request.draft == false
runs-on: ubuntu-20.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@v2
with:
persist-credentials: false
- name: Install dependencies
working-directory: ./superset-websocket
run: npm install
- name: lint
working-directory: ./superset-websocket
run: npm run lint
- name: prettier
working-directory: ./superset-websocket
run: npm run prettier-check
- name: unit tests
working-directory: ./superset-websocket
run: npm run test
- name: build
working-directory: ./superset-websocket
run: npm run build

View File

@ -0,0 +1,20 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
*.min.js
node_modules
dist
coverage

View File

@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
env: {
node: true,
browser: true,
},
plugins: [
'@typescript-eslint',
],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
rules: {
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-var-requires": 0,
},
};

20
superset-websocket/.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
config.json
dist
node_modules
*.log

View File

@ -0,0 +1 @@
v14.15.5

View File

@ -0,0 +1,24 @@
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
*.min.js
node_modules
dist
coverage
.eslintrc.js
.prettierrc.json
*.md
*.json

View File

@ -0,0 +1,5 @@
{
"trailingComma": "all",
"singleQuote": true,
"arrowParens": "avoid"
}

View File

@ -0,0 +1,106 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Superset WebSocket Server
A Node.js WebSocket server for sending async event data to the Superset web application frontend.
## Requirements
- Node.js 12+ (not tested with older versions)
- Redis 5+
To use this feature, Superset needs to be configured to enable global async queries and to use WebSockets as the transport (see below).
## Architecture
This implementation is based on the architecture defined in [SIP-39](https://github.com/apache/superset/issues/9190).
### Streams
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).
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.
### Connection
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).
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.
### Reconnection
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).
### Connection Management
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.
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.
## Install
Install dependencies:
```
npm install
```
## WebSocket Server Configuration
Copy `config.example.json` to `config.json` and adjust the values for your environment.
## Superset Configuration
Configure the Superset Flask app to enable global async queries (in `superset_config.py`):
Enable the `GLOBAL_ASYNC_QUERIES` feature flag:
```
"GLOBAL_ASYNC_QUERIES": True
```
Configure the following Superset values:
```
GLOBAL_ASYNC_QUERIES_TRANSPORT = "ws"
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.
The following config values must contain the same values in both the Flask app config and `config.json`:
```
GLOBAL_ASYNC_QUERIES_REDIS_CONFIG
GLOBAL_ASYNC_QUERIES_REDIS_STREAM_PREFIX
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME
GLOBAL_ASYNC_QUERIES_JWT_SECRET
```
More info on Superset configuration values for async queries: https://github.com/apache/superset/blob/master/CONTRIBUTING.md#async-chart-queries
## Running
Running locally via dev server:
```
npm run dev-server
```
Running in production:
```
npm run build && npm start
```
*TODO: containerization*

View File

@ -0,0 +1,16 @@
{
"port": 8080,
"logLevel": "info",
"logToFile": false,
"logFilename": "app.log",
"redis": {
"port": 6379,
"host": "127.0.0.1",
"password": "",
"db": 0,
"ssl": false
},
"redisStreamPrefix": "async-events-",
"jwtSecret": "CHANGE-ME",
"jwtCookieName": "async-token"
}

View File

@ -0,0 +1,12 @@
{
"redis": {
"port": 6379,
"host": "127.0.0.1",
"password": "",
"db": 10,
"ssl": false
},
"redisStreamPrefix": "test-async-events-",
"jwtSecret": "test123-test123-test123-test123-test123-test123-test123",
"jwtCookieName": "test-async-token"
}

View File

@ -0,0 +1,22 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

13474
superset-websocket/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
{
"name": "superset-websocket",
"version": "0.0.1",
"description": "Websocket sidecar application for Superset",
"main": "index.js",
"scripts": {
"start": "node dist/index.js start",
"test": "NODE_ENV=test jest -i spec",
"type": "tsc --noEmit",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx && npm run type",
"dev-server": "ts-node src/index.ts start",
"build": "tsc",
"prettier-check": "prettier --check .",
"format": "prettier --write ."
},
"license": "Apache-2.0",
"dependencies": {
"cookie": "^0.4.1",
"ioredis": "^4.16.1",
"jsonwebtoken": "^8.5.1",
"uuid": "^8.3.2",
"winston": "^3.3.3",
"ws": "^7.4.2"
},
"devDependencies": {
"@types/ioredis": "^4.22.0",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.22",
"@types/uuid": "^8.3.0",
"@types/ws": "^7.4.0",
"@typescript-eslint/eslint-plugin": "^4.19.0",
"@typescript-eslint/parser": "^4.19.0",
"eslint": "^7.22.0",
"eslint-config-prettier": "^8.1.0",
"jest": "^26.6.3",
"prettier": "2.2.1",
"ts-jest": "^26.5.3",
"ts-node": "^9.1.1",
"typescript": "^4.2.3"
}
}

View File

@ -0,0 +1,486 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const jwt = require('jsonwebtoken');
const config = require('../config.test.json');
import { describe, expect, test, beforeEach, afterEach } from '@jest/globals';
import * as http from 'http';
import * as net from 'net';
import WebSocket from 'ws';
// NOTE: these mock variables needs to start with "mock" due to
// calls to `jest.mock` being hoisted to the top of the file.
// https://jestjs.io/docs/es6-class-mocks#calling-jestmock-with-the-module-factory-parameter
const mockRedisXrange = jest.fn();
jest.mock('ws');
jest.mock('ioredis', () => {
return jest.fn().mockImplementation(() => {
return { xrange: mockRedisXrange };
});
});
const wsMock = WebSocket as jest.Mocked<typeof WebSocket>;
const channelId = 'bc9e040c-7b4a-4817-99b9-292832d97ec7';
const streamReturnValue: server.StreamResult[] = [
[
'1615426152415-0',
[
'data',
`{"channel_id": "${channelId}", "job_id": "c9b99965-8f1e-4ce5-aa43-d6fc94d6a510", "user_id": "1", "status": "done", "errors": [], "result_url": "/superset/explore_json/data/ejr-37281682b1282cdb8f25e0de0339b386"}`,
],
],
[
'1615426152516-0',
[
'data',
`{"channel_id": "${channelId}", "job_id": "f1e5bb1f-f2f1-4f21-9b2f-c9b91dcc9b59", "user_id": "1", "status": "done", "errors": [], "result_url": "/api/v1/chart/data/qc-64e8452dc9907dd77746cb75a19202de"}`,
],
],
];
import * as server from '../src/index';
describe('server', () => {
beforeEach(() => {
mockRedisXrange.mockClear();
server.resetState();
});
describe('incrementId', () => {
test('it increments a valid Redis stream ID', () => {
expect(server.incrementId('1607477697866-0')).toEqual('1607477697866-1');
});
test('it handles an invalid Redis stream ID', () => {
expect(server.incrementId('foo')).toEqual('foo');
});
});
describe('redisUrlFromConfig', () => {
test('it builds a valid Redis URL from defaults', () => {
expect(
server.redisUrlFromConfig({
port: 6379,
host: '127.0.0.1',
password: '',
db: 0,
ssl: false,
}),
).toEqual('redis://127.0.0.1:6379/0');
});
test('it builds a valid Redis URL with a password', () => {
expect(
server.redisUrlFromConfig({
port: 6380,
host: 'redis.local',
password: 'foo',
db: 1,
ssl: false,
}),
).toEqual('redis://:foo@redis.local:6380/1');
});
test('it builds a valid Redis URL with SSL', () => {
expect(
server.redisUrlFromConfig({
port: 6379,
host: '127.0.0.1',
password: '',
db: 0,
ssl: true,
}),
).toEqual('rediss://127.0.0.1:6379/0');
});
});
describe('processStreamResults', () => {
test('sends data to channel', async () => {
const ws = new wsMock('localhost');
const sendMock = jest.spyOn(ws, 'send');
const socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
server.trackClient(channelId, socketInstance);
server.processStreamResults(streamReturnValue);
const message1 = `{"id":"1615426152415-0","channel_id":"${channelId}","job_id":"c9b99965-8f1e-4ce5-aa43-d6fc94d6a510","user_id":"1","status":"done","errors":[],"result_url":"/superset/explore_json/data/ejr-37281682b1282cdb8f25e0de0339b386"}`;
const message2 = `{"id":"1615426152516-0","channel_id":"${channelId}","job_id":"f1e5bb1f-f2f1-4f21-9b2f-c9b91dcc9b59","user_id":"1","status":"done","errors":[],"result_url":"/api/v1/chart/data/qc-64e8452dc9907dd77746cb75a19202de"}`;
expect(sendMock).toHaveBeenCalledWith(message1);
expect(sendMock).toHaveBeenCalledWith(message2);
});
test('channel not present', async () => {
const ws = new wsMock('localhost');
const sendMock = jest.spyOn(ws, 'send');
server.processStreamResults(streamReturnValue);
expect(sendMock).not.toHaveBeenCalled();
});
test('error sending data to client', async () => {
const ws = new wsMock('localhost');
const sendMock = jest.spyOn(ws, 'send').mockImplementation(() => {
throw new Error();
});
const cleanChannelMock = jest.spyOn(server, 'cleanChannel');
const socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
server.trackClient(channelId, socketInstance);
server.processStreamResults(streamReturnValue);
expect(sendMock).toHaveBeenCalled();
expect(cleanChannelMock).toHaveBeenCalledWith(channelId);
cleanChannelMock.mockRestore();
});
});
describe('fetchRangeFromStream', () => {
beforeEach(() => {
mockRedisXrange.mockClear();
});
test('success with results', async () => {
mockRedisXrange.mockResolvedValueOnce(streamReturnValue);
const cb = jest.fn();
await server.fetchRangeFromStream({
sessionId: '123',
startId: '-',
endId: '+',
listener: cb,
});
expect(mockRedisXrange).toHaveBeenCalledWith(
'test-async-events-123',
'-',
'+',
);
expect(cb).toHaveBeenCalledWith(streamReturnValue);
});
test('success no results', async () => {
const cb = jest.fn();
await server.fetchRangeFromStream({
sessionId: '123',
startId: '-',
endId: '+',
listener: cb,
});
expect(mockRedisXrange).toHaveBeenCalledWith(
'test-async-events-123',
'-',
'+',
);
expect(cb).not.toHaveBeenCalled();
});
test('error', async () => {
const cb = jest.fn();
mockRedisXrange.mockRejectedValueOnce(new Error());
await server.fetchRangeFromStream({
sessionId: '123',
startId: '-',
endId: '+',
listener: cb,
});
expect(mockRedisXrange).toHaveBeenCalledWith(
'test-async-events-123',
'-',
'+',
);
expect(cb).not.toHaveBeenCalled();
});
});
describe('wsConnection', () => {
let ws: WebSocket;
let wsEventMock: jest.SpyInstance;
let trackClientSpy: jest.SpyInstance;
let fetchRangeFromStreamSpy: jest.SpyInstance;
let dateNowSpy: jest.SpyInstance;
let socketInstanceExpected: server.SocketInstance;
const getRequest = (token: string, url: string): http.IncomingMessage => {
const request = new http.IncomingMessage(new net.Socket());
request.method = 'GET';
request.headers = { cookie: `${config.jwtCookieName}=${token}` };
request.url = url;
return request;
};
beforeEach(() => {
ws = new wsMock('localhost');
wsEventMock = jest.spyOn(ws, 'on');
trackClientSpy = jest.spyOn(server, 'trackClient');
fetchRangeFromStreamSpy = jest.spyOn(server, 'fetchRangeFromStream');
dateNowSpy = jest
.spyOn(global.Date, 'now')
.mockImplementation(() =>
new Date('2021-03-10T11:01:58.135Z').valueOf(),
);
socketInstanceExpected = {
ws,
channel: channelId,
pongTs: 1615374118135,
};
});
afterEach(() => {
wsEventMock.mockRestore();
trackClientSpy.mockRestore();
fetchRangeFromStreamSpy.mockRestore();
dateNowSpy.mockRestore();
});
test('invalid JWT', async () => {
const invalidToken = jwt.sign({ channel: channelId }, 'invalid secret');
const request = getRequest(invalidToken, 'http://localhost');
expect(() => {
server.wsConnection(ws, request);
}).toThrow();
});
test('valid JWT, no lastId', async () => {
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
const request = getRequest(validToken, 'http://localhost');
server.wsConnection(ws, request);
expect(trackClientSpy).toHaveBeenCalledWith(
channelId,
socketInstanceExpected,
);
expect(fetchRangeFromStreamSpy).not.toHaveBeenCalled();
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
});
test('valid JWT, with lastId', async () => {
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
const lastId = '1615426152415-0';
const request = getRequest(
validToken,
`http://localhost?last_id=${lastId}`,
);
server.wsConnection(ws, request);
expect(trackClientSpy).toHaveBeenCalledWith(
channelId,
socketInstanceExpected,
);
expect(fetchRangeFromStreamSpy).toHaveBeenCalledWith({
sessionId: channelId,
startId: '1615426152415-1',
endId: '+',
listener: server.processStreamResults,
});
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
});
test('valid JWT, with lastId and lastFirehoseId', async () => {
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
const lastId = '1615426152415-0';
const lastFirehoseId = '1715426152415-0';
const request = getRequest(
validToken,
`http://localhost?last_id=${lastId}`,
);
server.setLastFirehoseId(lastFirehoseId);
server.wsConnection(ws, request);
expect(trackClientSpy).toHaveBeenCalledWith(
channelId,
socketInstanceExpected,
);
expect(fetchRangeFromStreamSpy).toHaveBeenCalledWith({
sessionId: channelId,
startId: '1615426152415-1',
endId: lastFirehoseId,
listener: server.processStreamResults,
});
expect(wsEventMock).toHaveBeenCalledWith('pong', expect.any(Function));
});
});
describe('httpUpgrade', () => {
let socket: net.Socket;
let socketDestroySpy: jest.SpyInstance;
let wssUpgradeSpy: jest.SpyInstance;
const getRequest = (token: string, url: string): http.IncomingMessage => {
const request = new http.IncomingMessage(new net.Socket());
request.method = 'GET';
request.headers = { cookie: `${config.jwtCookieName}=${token}` };
request.url = url;
return request;
};
beforeEach(() => {
socket = new net.Socket();
socketDestroySpy = jest.spyOn(socket, 'destroy');
wssUpgradeSpy = jest.spyOn(server.wss, 'handleUpgrade');
});
afterEach(() => {
wssUpgradeSpy.mockRestore();
});
test('invalid JWT', async () => {
const invalidToken = jwt.sign({ channel: channelId }, 'invalid secret');
const request = getRequest(invalidToken, 'http://localhost');
server.httpUpgrade(request, socket, Buffer.alloc(5));
expect(socketDestroySpy).toHaveBeenCalled();
expect(wssUpgradeSpy).not.toHaveBeenCalled();
});
test('valid JWT, no channel', async () => {
const validToken = jwt.sign({ foo: 'bar' }, config.jwtSecret);
const request = getRequest(validToken, 'http://localhost');
server.httpUpgrade(request, socket, Buffer.alloc(5));
expect(socketDestroySpy).toHaveBeenCalled();
expect(wssUpgradeSpy).not.toHaveBeenCalled();
});
test('valid upgrade', async () => {
const validToken = jwt.sign({ channel: channelId }, config.jwtSecret);
const request = getRequest(validToken, 'http://localhost');
server.httpUpgrade(request, socket, Buffer.alloc(5));
expect(socketDestroySpy).not.toHaveBeenCalled();
expect(wssUpgradeSpy).toHaveBeenCalled();
});
});
describe('checkSockets', () => {
let ws: WebSocket;
let pingSpy: jest.SpyInstance;
let terminateSpy: jest.SpyInstance;
let socketInstance: server.SocketInstance;
beforeEach(() => {
ws = new wsMock('localhost');
pingSpy = jest.spyOn(ws, 'ping');
terminateSpy = jest.spyOn(ws, 'terminate');
socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
});
test('active sockets', () => {
ws.readyState = WebSocket.OPEN;
server.trackClient(channelId, socketInstance);
server.checkSockets();
expect(pingSpy).toHaveBeenCalled();
expect(terminateSpy).not.toHaveBeenCalled();
expect(Object.keys(server.sockets).length).toBe(1);
});
test('stale sockets', () => {
ws.readyState = WebSocket.OPEN;
socketInstance.pongTs = Date.now() - 60000;
server.trackClient(channelId, socketInstance);
server.checkSockets();
expect(pingSpy).not.toHaveBeenCalled();
expect(terminateSpy).toHaveBeenCalled();
expect(Object.keys(server.sockets).length).toBe(0);
});
test('closed sockets', () => {
ws.readyState = WebSocket.CLOSED;
server.trackClient(channelId, socketInstance);
server.checkSockets();
expect(pingSpy).not.toHaveBeenCalled();
expect(terminateSpy).not.toHaveBeenCalled();
expect(Object.keys(server.sockets).length).toBe(0);
});
test('no sockets', () => {
// don't error
server.checkSockets();
});
});
describe('cleanChannel', () => {
let ws: WebSocket;
let socketInstance: server.SocketInstance;
beforeEach(() => {
ws = new wsMock('localhost');
socketInstance = { ws: ws, channel: channelId, pongTs: Date.now() };
});
test('active sockets', () => {
ws.readyState = WebSocket.OPEN;
server.trackClient(channelId, socketInstance);
server.cleanChannel(channelId);
expect(server.channels[channelId].sockets.length).toBe(1);
});
test('closing sockets', () => {
ws.readyState = WebSocket.CLOSING;
server.trackClient(channelId, socketInstance);
server.cleanChannel(channelId);
expect(server.channels[channelId]).toBeUndefined();
});
test('multiple sockets', () => {
ws.readyState = WebSocket.OPEN;
server.trackClient(channelId, socketInstance);
const ws2 = new wsMock('localhost');
ws2.readyState = WebSocket.OPEN;
const socketInstance2 = {
ws: ws2,
channel: channelId,
pongTs: Date.now(),
};
server.trackClient(channelId, socketInstance2);
server.cleanChannel(channelId);
expect(server.channels[channelId].sockets.length).toBe(2);
ws2.readyState = WebSocket.CLOSED;
server.cleanChannel(channelId);
expect(server.channels[channelId].sockets.length).toBe(1);
});
test('invalid channel', () => {
// don't error
server.cleanChannel(channelId);
});
});
});

View File

@ -0,0 +1,465 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as http from 'http';
import * as net from 'net';
import WebSocket from 'ws';
import { v4 as uuidv4 } from 'uuid';
const winston = require('winston');
const jwt = require('jsonwebtoken');
const cookie = require('cookie');
const Redis = require('ioredis');
export type StreamResult = [
recordId: string,
record: [label: 'data', data: string],
];
// sync with superset-frontend/src/components/ErrorMessage/types
export type ErrorLevel = 'info' | 'warning' | 'error';
export type SupersetError<ExtraType = Record<string, any> | null> = {
error_type: string;
extra: ExtraType;
level: ErrorLevel;
message: string;
};
type ListenerFunction = (results: StreamResult[]) => void;
interface EventValue {
id: string;
channel_id: string;
job_id: string;
user_id?: string;
status: string;
errors?: SupersetError[];
result_url?: string;
}
interface JwtPayload {
channel: string;
}
interface FetchRangeFromStreamParams {
sessionId: string;
startId: string;
endId: string;
listener: ListenerFunction;
}
export interface SocketInstance {
ws: WebSocket;
channel: string;
pongTs: number;
}
interface RedisConfig {
port: number;
host: string;
password?: string | null;
db: number;
ssl: boolean;
}
interface ChannelValue {
sockets: Array<string>;
}
const environment = process.env.NODE_ENV;
// default options
export const opts = {
port: 8080,
logLevel: 'info',
logToFile: false,
logFilename: 'app.log',
redis: {
port: 6379,
host: '127.0.0.1',
password: '',
db: 0,
ssl: false,
},
redisStreamPrefix: 'async-events-',
redisStreamReadCount: 100,
redisStreamReadBlockMs: 5000,
jwtSecret: '',
jwtCookieName: 'async-token',
socketResponseTimeoutMs: 60 * 1000,
pingSocketsIntervalMs: 20 * 1000,
gcChannelsIntervalMs: 120 * 1000,
};
const startServer = process.argv[2] === 'start';
const configFile =
environment === 'test' ? '../config.test.json' : '../config.json';
let config = {};
try {
config = require(configFile);
} catch (err) {
console.error('config.json not found, using defaults');
}
// apply config overrides
Object.assign(opts, config);
// init logger
const logTransports = [
new winston.transports.Console({ handleExceptions: true }),
];
if (opts.logToFile && opts.logFilename) {
logTransports.push(
new winston.transports.File({
filename: opts.logFilename,
handleExceptions: true,
}),
);
}
const logger = winston.createLogger({
level: opts.logLevel,
transports: logTransports,
});
// enforce JWT secret length
if (startServer && opts.jwtSecret.length < 32)
throw new Error('Please provide a JWT secret at least 32 bytes long');
export const redisUrlFromConfig = (redisConfig: RedisConfig): string => {
let url = redisConfig.ssl ? 'rediss://' : 'redis://';
if (redisConfig.password) url += `:${redisConfig.password}@`;
url += `${redisConfig.host}:${redisConfig.port}/${redisConfig.db}`;
return url;
};
// initialize servers
const redis = new Redis(redisUrlFromConfig(opts.redis));
const httpServer = http.createServer();
export const wss = new WebSocket.Server({
noServer: true,
clientTracking: false,
});
const SOCKET_ACTIVE_STATES = [WebSocket.OPEN, WebSocket.CONNECTING];
const GLOBAL_EVENT_STREAM_NAME = `${opts.redisStreamPrefix}full`;
const DEFAULT_STREAM_LAST_ID = '$';
// initialize internal registries
export let channels: Record<string, ChannelValue> = {};
export let sockets: Record<string, SocketInstance> = {};
let lastFirehoseId: string = DEFAULT_STREAM_LAST_ID;
export const setLastFirehoseId = (id: string): void => {
lastFirehoseId = id;
};
/**
* Adds the passed channel and socket instance to the internal registries.
*/
export const trackClient = (
channel: string,
socketInstance: SocketInstance,
): string => {
const socketId = uuidv4();
sockets[socketId] = socketInstance;
if (channel in channels) {
channels[channel].sockets.push(socketId);
} else {
channels[channel] = { sockets: [socketId] };
}
return socketId;
};
/**
* Sends a single async event payload to a single channel.
* A channel may have multiple connected sockets, this emits
* the event to all connected sockets within a channel.
*/
export const sendToChannel = (channel: string, value: EventValue): void => {
const strData = JSON.stringify(value);
if (!channels[channel]) {
logger.debug(`channel ${channel} is unknown, skipping`);
return;
}
channels[channel].sockets.forEach(socketId => {
const socketInstance: SocketInstance = sockets[socketId];
if (!socketInstance) return cleanChannel(channel);
try {
socketInstance.ws.send(strData);
} catch (err) {
logger.debug(`Error sending to socket: ${err}`);
// check that the connection is still active
cleanChannel(channel);
}
});
};
/**
* Reads a range of events from a channel-specific Redis event stream.
* Invoked in the client re-connection flow.
*/
export const fetchRangeFromStream = async ({
sessionId,
startId,
endId,
listener,
}: FetchRangeFromStreamParams) => {
const streamName = `${opts.redisStreamPrefix}${sessionId}`;
try {
const reply = await redis.xrange(streamName, startId, endId);
if (!reply || !reply.length) return;
listener(reply);
} catch (e) {
logger.error(e);
}
};
/**
* Reads from the global Redis event stream continuously.
* Utilizes a blocking connection to Redis to wait for data to
* be returned from the stream.
*/
export const subscribeToGlobalStream = async (
stream: string,
listener: ListenerFunction,
) => {
/*eslint no-constant-condition: ["error", { "checkLoops": false }]*/
while (true) {
try {
const reply = await redis.xread(
'BLOCK',
opts.redisStreamReadBlockMs,
'COUNT',
opts.redisStreamReadCount,
'STREAMS',
stream,
lastFirehoseId,
);
if (!reply) {
continue;
}
const results = reply[0][1];
const { length } = results;
if (!results.length) {
continue;
}
listener(results);
setLastFirehoseId(results[length - 1][0]);
} catch (e) {
logger.error(e);
continue;
}
}
};
/**
* Callback function to process events received from a Redis Stream
*/
export const processStreamResults = (results: StreamResult[]): void => {
logger.debug(`events received: ${results}`);
results.forEach(item => {
try {
const id = item[0];
const data = JSON.parse(item[1][1]);
sendToChannel(data.channel_id, { id, ...data });
} catch (err) {
logger.error(err);
}
});
};
/**
* Verify and parse a JWT cookie from an HTTP request.
* Returns the JWT payload or throws an error on invalid token.
*/
const getJwtPayload = (request: http.IncomingMessage): JwtPayload => {
const cookies = cookie.parse(request.headers.cookie);
const token = cookies[opts.jwtCookieName];
if (!token) throw new Error('JWT not present');
return jwt.verify(token, opts.jwtSecret);
};
/**
* Extracts the `last_id` query param value from an HTTP request
*/
const getLastId = (request: http.IncomingMessage): string | null => {
const url = new URL(String(request.url), 'http://0.0.0.0');
const queryParams = url.searchParams;
return queryParams.get('last_id');
};
/**
* Increments a Redis Stream ID
*/
export const incrementId = (id: string): string => {
// redis stream IDs are in this format: '1607477697866-0'
const parts = id.split('-');
if (parts.length < 2) return id;
return parts[0] + '-' + (Number(parts[1]) + 1);
};
/**
* WebSocket `connection` event handler, called via wss
*/
export const wsConnection = (ws: WebSocket, request: http.IncomingMessage) => {
const jwtPayload: JwtPayload = getJwtPayload(request);
const channel: string = jwtPayload.channel;
const socketInstance: SocketInstance = { ws, channel, pongTs: Date.now() };
// add this ws instance to the internal registry
const socketId = trackClient(channel, socketInstance);
logger.debug(`socket ${socketId} connected on channel ${channel}`);
// reconnection logic
const lastId = getLastId(request);
if (lastId) {
// fetch range of events from lastId to most recent event received on
// via global event stream
const endId =
lastFirehoseId === DEFAULT_STREAM_LAST_ID ? '+' : lastFirehoseId;
fetchRangeFromStream({
sessionId: channel,
startId: incrementId(lastId), // inclusive
endId, // inclusive
listener: processStreamResults,
});
}
// init event handler for `pong` events (connection management)
ws.on('pong', function pong(data: Buffer) {
const socketId = data.toString();
const socketInstance = sockets[socketId];
if (!socketInstance) {
logger.warn(`pong received for nonexistent socket ${socketId}`);
} else {
socketInstance.pongTs = Date.now();
}
});
};
/**
* HTTP `upgrade` event handler, called via httpServer
*/
export const httpUpgrade = (
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer,
) => {
try {
const jwtPayload: JwtPayload = getJwtPayload(request);
if (!jwtPayload.channel) throw new Error('Channel ID not present');
} catch (err) {
// JWT invalid, do not establish a WebSocket connection
logger.error(err);
socket.destroy();
return;
}
// upgrade the HTTP request into a WebSocket connection
wss.handleUpgrade(
request,
socket,
head,
function cb(ws: WebSocket, request: http.IncomingMessage) {
wss.emit('connection', ws, request);
},
);
};
// Connection cleanup and garbage collection
/**
* Iterate over all tracked sockets, terminating and removing references to
* connections that have not responded with a _pong_ within the timeout window.
* Sends a _ping_ to all active connections.
*/
export const checkSockets = () => {
logger.debug(`channel count: ${Object.keys(channels).length}`);
logger.debug(`socket count: ${Object.keys(sockets).length}`);
for (const socketId in sockets) {
const socketInstance = sockets[socketId];
const timeout = Date.now() - socketInstance.pongTs;
let isActive = true;
if (timeout >= opts.socketResponseTimeoutMs) {
logger.debug(
`terminating unresponsive socket: ${socketId}, channel: ${socketInstance.channel}`,
);
socketInstance.ws.terminate();
isActive = false;
} else if (!SOCKET_ACTIVE_STATES.includes(socketInstance.ws.readyState)) {
isActive = false;
}
if (isActive) {
socketInstance.ws.ping(socketId);
} else {
delete sockets[socketId];
logger.debug(`forgetting socket ${socketId}`);
}
}
};
/**
* Iterate over all sockets within a channel, removing references to
* inactive connections, ultimately removing the channel from the
* _channels_ registry if no active connections remain.
*/
export const cleanChannel = (channel: string) => {
const activeSockets: string[] =
channels[channel]?.sockets.filter(socketId => {
const socketInstance = sockets[socketId];
if (!socketInstance) return false;
if (SOCKET_ACTIVE_STATES.includes(socketInstance.ws.readyState))
return true;
return false;
}) || [];
if (activeSockets.length === 0) {
delete channels[channel];
} else {
channels[channel].sockets = activeSockets;
}
};
// server startup
if (startServer) {
// init server event listeners
wss.on('connection', wsConnection);
httpServer.on('upgrade', httpUpgrade);
httpServer.listen(opts.port);
logger.info(`Server started on port ${opts.port}`);
// start reading from event stream
subscribeToGlobalStream(GLOBAL_EVENT_STREAM_NAME, processStreamResults);
// init garbage collection routines
setInterval(checkSockets, opts.pingSocketsIntervalMs);
setInterval(function gc() {
// clean all channels
for (const channel in channels) {
cleanChannel(channel);
}
}, opts.gcChannelsIntervalMs);
}
// test utilities
export const resetState = () => {
channels = {};
sockets = {};
lastFirehoseId = DEFAULT_STREAM_LAST_ID;
};

View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"outDir": "dist",
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
}

View File

@ -0,0 +1,35 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Test & development utilities
The files provided here are for testing and development only, and are not required to run the WebSocket server application.
## Test client application
The Express web application in `client-ws-app` is provided for testing the WebSocket server. See `client-ws-app/README.md` for details.
## Load testing script
The `loadtest.js` script is provided to populate the Redis streams with event data.
### Running
```
node loadtest.js
```
The script will populate data continually until the script is exited using CTRL-C.
**Note:** `loadtest.js` and test client application are configured to use the server's local `config.json` values, so care should be taken to not overwrite any sensitive data.

View File

@ -0,0 +1 @@
v14.15.5

View File

@ -0,0 +1,42 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Test client application
This Express web application is provided for testing the WebSocket server. It is not required for running the server application, and is provided here for testing and development purposes only.
## Running
First, start the WebSocket server:
```
cd ..
npm run dev-server
```
Then run the client application:
```
cd client-ws-app
npm install
npm start
```
Open http://127.0.0.1:3000 in your web browser.
You can customize the number of WebSocket connections by passing the count in the `sockets` query param, e.g. `http://127.0.0.1:3000?sockets=180`, though beware that browsers limit the number of open WebSocket connections to around 200.
Run in conjunction with the `loadtest.js` script to populate the Redis streams with event data.
**Note:** this test application is configured to use the server's local `config.json` values, so care should be taken to not overwrite any sensitive data.

View File

@ -0,0 +1,57 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var indexRouter = require('./routes/index');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', indexRouter);
// catch 404 and forward to error handler
app.use(function (req, res, next) {
next(createError(404));
});
// error handler
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 : {};
// render the error page
res.status(err.status || 500);
res.render('error');
});
module.exports = app;

View File

@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('client-ws-app:server');
var http = require('http');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
{
"name": "client-ws-app",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www"
},
"dependencies": {
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"express": "~4.16.1",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"jsonwebtoken": "^8.5.1",
"morgan": "~1.9.1"
}
}

View File

@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
let socketCount = 0;
let messageCount = 0;
let lastMessage;
function ts() {
return new Date().getTime();
}
const cookieName = document.getElementById('cookie').innerHTML;
const tokenData = document.getElementById('tokens').innerHTML;
const tokens = JSON.parse(tokenData);
function connect() {
if (socketCount >= tokens.length) return;
// using https://github.com/js-cookie/js-cookie
// eslint-disable-next-line no-undef
Cookies.set(cookieName, tokens[socketCount], { path: '' });
// Create WebSocket connection.
let url = `ws://127.0.0.1:8080?last_id=${ts()}`;
const socket = new WebSocket(url);
// Connection opened
socket.addEventListener('open', function () {
socketCount++;
document.getElementById('socket-count').innerHTML = socketCount;
connect();
socket.send('Hello Server!');
});
// Listen for messages
socket.addEventListener('message', function (event) {
messageCount++;
lastMessage = event.data;
});
}
connect();
setInterval(() => {
document.getElementById('message-count').innerHTML = messageCount;
document.getElementById('message-debug').innerHTML = lastMessage;
}, 250);

View File

@ -0,0 +1,37 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
body {
padding: 50px;
font: 14px 'Lucida Grande', Helvetica, Arial, sans-serif;
}
a {
color: #00b7ff;
}
span {
font-weight: bold;
}
code {
display: inline-block;
margin-top: 5px;
border: 1px #999 solid;
padding: 5px;
}

View File

@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const config = require('../../../config.json');
router.get('/', function (req, res) {
let numTokens = req.query.sockets ? Number(req.query.sockets) : 100;
let tokens = [];
for (let i = 0; i < numTokens; i++) {
const token = jwt.sign({ channel: String(i) }, config.jwtSecret);
tokens.push(token);
}
res.render('index', {
tokens: JSON.stringify(tokens),
c: config.jwtCookieName,
});
});
module.exports = router;

View File

@ -0,0 +1,23 @@
//-
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
extends layout
block content
h1= message
h2= error.status
pre #{error.stack}

View File

@ -0,0 +1,32 @@
//-
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
extends layout
block content
h1 Websocket test app
#tokens(style="display:none")= tokens
#cookie(style="display:none")= c
div Sockets connected:
span#socket-count 0
div Messages recevied:
span#message-count 0
div Last message received:
code#message-debug
script(src="/javascripts/app.js")

View File

@ -0,0 +1,26 @@
//-
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
doctype html
html
head
title= title
link(rel='stylesheet', href='/stylesheets/style.css')
script(src="https://cdn.jsdelivr.net/npm/js-cookie@rc/dist/js.cookie.min.js")
body
block content

View File

@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const { v4: uuidv4 } = require('uuid');
const Redis = require('ioredis');
const config = require('../config.json');
const redis = new Redis(config.redis);
const numClients = 256;
const globalEventStreamName = `${config.redisStreamPrefix}full`;
function pushData() {
for (let i = 0; i < numClients; i++) {
const channelId = String(i);
const streamId = `${config.redisStreamPrefix}${channelId}`;
const data = {
channel_id: channelId,
job_id: uuidv4(),
status: 'pending',
};
// push to channel stream
redis
.xadd(streamId, 'MAXLEN', 1000, '*', 'data', JSON.stringify(data))
.then(resp => {
console.log('stream response', resp);
});
// push to firehose (all events) stream
redis
.xadd(
globalEventStreamName,
'MAXLEN',
100000,
'*',
'data',
JSON.stringify(data),
)
.then(resp => {
console.log('stream response', resp);
});
}
}
pushData();
setInterval(pushData, 1000);

View File

@ -1134,6 +1134,7 @@ GLOBAL_ASYNC_QUERIES_REDIS_CONFIG = {
"host": "127.0.0.1",
"password": "",
"db": 0,
"ssl": False,
}
GLOBAL_ASYNC_QUERIES_REDIS_STREAM_PREFIX = "async-events-"
GLOBAL_ASYNC_QUERIES_REDIS_STREAM_LIMIT = 1000
@ -1143,6 +1144,7 @@ GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE = False
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/"
# It's possible to add a dataset health check logic which is specific to your system.
# It will get executed each time when user open a chart's explore view.

View File

@ -21,7 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
import jwt
import redis
from flask import Flask, Request, Response, session
from flask import Flask, request, Request, Response, session
logger = logging.getLogger(__name__)
@ -111,13 +111,14 @@ class AsyncQueryManager:
def validate_session( # pylint: disable=unused-variable
response: Response,
) -> Response:
reset_token = False
user_id = session["user_id"] if "user_id" in session else None
if "async_channel_id" not in session or "async_user_id" not in session:
reset_token = True
elif user_id != session["async_user_id"]:
reset_token = True
reset_token = (
not request.cookies.get(self._jwt_cookie_name)
or "async_channel_id" not in session
or "async_user_id" not in session
or user_id != session["async_user_id"]
)
if reset_token:
async_channel_id = str(uuid.uuid4())
@ -132,10 +133,6 @@ class AsyncQueryManager:
value=token,
httponly=True,
secure=self._jwt_cookie_secure,
# max_age=max_age or config.cookie_max_age,
# domain=config.cookie_domain,
# path=config.access_cookie_path,
# samesite=config.cookie_samesite
)
return response
@ -148,8 +145,8 @@ class AsyncQueryManager:
data = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
return data
def parse_jwt_from_request(self, request: Request) -> Dict[str, Any]:
token = request.cookies.get(self._jwt_cookie_name)
def parse_jwt_from_request(self, req: Request) -> Dict[str, Any]:
token = req.cookies.get(self._jwt_cookie_name)
if not token:
raise AsyncQueryTokenException("Token not preset")

View File

@ -85,6 +85,7 @@ FRONTEND_CONF_KEYS = (
"GLOBAL_ASYNC_QUERIES_POLLING_DELAY",
"SQLALCHEMY_DOCS_URL",
"SQLALCHEMY_DISPLAY_TEXT",
"GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL",
)
logger = logging.getLogger(__name__)