commit
a570cf16b0
@ -47,6 +47,7 @@
|
|||||||
"exceljs": "1.9.1",
|
"exceljs": "1.9.1",
|
||||||
"file-saver": "^2.0.1",
|
"file-saver": "^2.0.1",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
|
"lz4js": "^0.2.0",
|
||||||
"material-icon-font": "git+https://github.com/petergng/materialIconFont.git",
|
"material-icon-font": "git+https://github.com/petergng/materialIconFont.git",
|
||||||
"ng-pick-datetime": "^7.0.0",
|
"ng-pick-datetime": "^7.0.0",
|
||||||
"ng2-pdf-viewer": "^5.2.3",
|
"ng2-pdf-viewer": "^5.2.3",
|
||||||
|
@ -5,6 +5,7 @@ import { Router } from '@angular/router';
|
|||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { Observable, Subject } from 'rxjs';
|
import { Observable, Subject } from 'rxjs';
|
||||||
import { take } from 'rxjs/operators';
|
import { take } from 'rxjs/operators';
|
||||||
|
import { compress, decompress } from 'lz4js';
|
||||||
|
|
||||||
import { formatQueryParams, QueryParams } from '../query-params';
|
import { formatQueryParams, QueryParams } from '../query-params';
|
||||||
import { OpenSlidesStatusService } from './openslides-status.service';
|
import { OpenSlidesStatusService } from './openslides-status.service';
|
||||||
@ -136,7 +137,7 @@ export class WebsocketService {
|
|||||||
return this._connectionOpen;
|
return this._connectionOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendQueueWhileNotConnected: string[] = [];
|
private sendQueueWhileNotConnected: (string | ArrayBuffer)[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The websocket.
|
* The websocket.
|
||||||
@ -219,6 +220,7 @@ export class WebsocketService {
|
|||||||
socketPath += formatQueryParams(queryParams);
|
socketPath += formatQueryParams(queryParams);
|
||||||
|
|
||||||
this.websocket = new WebSocket(socketPath);
|
this.websocket = new WebSocket(socketPath);
|
||||||
|
this.websocket.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
// connection established. If this connect attept was a retry,
|
// connection established. If this connect attept was a retry,
|
||||||
// The error notice will be removed and the reconnectSubject is published.
|
// The error notice will be removed and the reconnectSubject is published.
|
||||||
@ -270,8 +272,20 @@ export class WebsocketService {
|
|||||||
*
|
*
|
||||||
* @param data The message
|
* @param data The message
|
||||||
*/
|
*/
|
||||||
private handleMessage(data: string): void {
|
private handleMessage(data: string | ArrayBuffer): void {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
const compressedSize = data.byteLength;
|
||||||
|
const decompressedBuffer: Uint8Array = decompress(new Uint8Array(data));
|
||||||
|
console.log(
|
||||||
|
`Recieved ${compressedSize / 1024} KB (${decompressedBuffer.byteLength /
|
||||||
|
1024} KB uncompressed), ratio ${decompressedBuffer.byteLength / compressedSize}`
|
||||||
|
);
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
data = textDecoder.decode(decompressedBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
const message: IncommingWebsocketMessage = JSON.parse(data);
|
const message: IncommingWebsocketMessage = JSON.parse(data);
|
||||||
|
console.log('Received', message);
|
||||||
const type = message.type;
|
const type = message.type;
|
||||||
const inResponse = message.in_response;
|
const inResponse = message.in_response;
|
||||||
const callbacks = this.responseCallbacks[inResponse];
|
const callbacks = this.responseCallbacks[inResponse];
|
||||||
@ -447,10 +461,18 @@ export class WebsocketService {
|
|||||||
|
|
||||||
// Either send directly or add to queue, if not connected.
|
// Either send directly or add to queue, if not connected.
|
||||||
const jsonMessage = JSON.stringify(message);
|
const jsonMessage = JSON.stringify(message);
|
||||||
|
|
||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
const bytesMessage = textEncoder.encode(jsonMessage);
|
||||||
|
const compressedMessage: ArrayBuffer = compress(bytesMessage);
|
||||||
|
const ratio = bytesMessage.byteLength / compressedMessage.byteLength;
|
||||||
|
|
||||||
|
const toSend = ratio > 1 ? compressedMessage : jsonMessage;
|
||||||
|
|
||||||
if (this.isConnected) {
|
if (this.isConnected) {
|
||||||
this.websocket.send(jsonMessage);
|
this.websocket.send(toSend);
|
||||||
} else {
|
} else {
|
||||||
this.sendQueueWhileNotConnected.push(jsonMessage);
|
this.sendQueueWhileNotConnected.push(toSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
return message.id;
|
return message.id;
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
class WebsocketLatencyLogger:
|
class WebsocketLatencyLogger:
|
||||||
@ -60,3 +60,79 @@ class WebsocketLatencyLogger:
|
|||||||
""" Resets the stats. """
|
""" Resets the stats. """
|
||||||
self.latencies: List[int] = []
|
self.latencies: List[int] = []
|
||||||
self.time = time.time()
|
self.time = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketThroughputLogger:
|
||||||
|
"""
|
||||||
|
Usage (give values in bytes):
|
||||||
|
- WebsocketThroughputLogger.send(<uncompressed>, <compressed>)
|
||||||
|
- WebsocketThroughputLogger.recieve(<uncompressed>, <compressed>)
|
||||||
|
The compressed value is optional. If the data is not compressed, just
|
||||||
|
give the uncompressed value.
|
||||||
|
Note: Only the send values are logged in KB (received values in bytes).
|
||||||
|
"""
|
||||||
|
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
""" To access the stats variables. """
|
||||||
|
|
||||||
|
instance = None
|
||||||
|
""" The only throughputlogger instance. """
|
||||||
|
|
||||||
|
logger = logging.getLogger("openslides.websocket.throughput")
|
||||||
|
""" The logger to log to. """
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def send(cls, uncompressed: int, compressed: Optional[int] = None) -> None:
|
||||||
|
# pass the latency value to the single instance
|
||||||
|
async with cls.lock:
|
||||||
|
if cls.instance is None:
|
||||||
|
cls.instance = cls()
|
||||||
|
if compressed is None:
|
||||||
|
compressed = uncompressed
|
||||||
|
cls.instance.send_uncompressed += int(uncompressed / 1024)
|
||||||
|
cls.instance.send_compressed += int(compressed / 1024)
|
||||||
|
await cls.instance.check_and_flush()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def receive(cls, uncompressed: int, compressed: Optional[int] = None) -> None:
|
||||||
|
# pass the latency value to the single instance
|
||||||
|
async with cls.lock:
|
||||||
|
if cls.instance is None:
|
||||||
|
cls.instance = cls()
|
||||||
|
if compressed is None:
|
||||||
|
compressed = uncompressed
|
||||||
|
cls.instance.receive_uncompressed += uncompressed
|
||||||
|
cls.instance.receive_compressed += compressed
|
||||||
|
await cls.instance.check_and_flush()
|
||||||
|
|
||||||
|
async def check_and_flush(self) -> None:
|
||||||
|
# If we waited longer then 60 seconds, flush the data.
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time > (self.time + 20):
|
||||||
|
|
||||||
|
send_ratio = receive_ratio = 1.0
|
||||||
|
if self.send_compressed > 0:
|
||||||
|
send_ratio = self.send_uncompressed / self.send_compressed
|
||||||
|
if self.receive_compressed > 0:
|
||||||
|
receive_ratio = self.receive_uncompressed / self.receive_compressed
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
f"tx_uncompressed={self.send_uncompressed} KB, "
|
||||||
|
f"tx_compressed={self.send_compressed} KB, "
|
||||||
|
f"tx_ratio={send_ratio:.2f}, "
|
||||||
|
f"rx_uncompressed={self.receive_uncompressed} B, "
|
||||||
|
f"rx_compressed={self.receive_compressed} B, "
|
||||||
|
f"rx_ratio={receive_ratio:.2f}"
|
||||||
|
)
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
""" Resets the stats. """
|
||||||
|
self.send_compressed = 0
|
||||||
|
self.send_uncompressed = 0
|
||||||
|
self.receive_compressed = 0
|
||||||
|
self.receive_uncompressed = 0
|
||||||
|
self.time = time.time()
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
|
import json
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import jsonschema
|
import jsonschema
|
||||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
import lz4.frame
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from django.conf import settings
|
||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed
|
||||||
|
|
||||||
from .autoupdate import AutoupdateFormat
|
from .autoupdate import AutoupdateFormat
|
||||||
from .cache import element_cache
|
from .cache import element_cache
|
||||||
|
from .stats import WebsocketThroughputLogger
|
||||||
from .utils import split_element_id
|
from .utils import split_element_id
|
||||||
|
|
||||||
|
|
||||||
@ -25,12 +29,57 @@ WEBSOCKET_WRONG_FORMAT = 10
|
|||||||
# If the recieved data has not the expected format.
|
# If the recieved data has not the expected format.
|
||||||
|
|
||||||
|
|
||||||
class ProtocollAsyncJsonWebsocketConsumer(AsyncJsonWebsocketConsumer):
|
class AsyncCompressedJsonWebsocketConsumer(AsyncWebsocketConsumer):
|
||||||
|
async def receive(
|
||||||
|
self,
|
||||||
|
text_data: Optional[str] = None,
|
||||||
|
bytes_data: Optional[bytes] = None,
|
||||||
|
**kwargs: Dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
if bytes_data:
|
||||||
|
uncompressed_data = lz4.frame.decompress(bytes_data)
|
||||||
|
text_data = uncompressed_data.decode("utf-8")
|
||||||
|
|
||||||
|
recv_len = len(bytes_data)
|
||||||
|
uncompressed_len = len(uncompressed_data)
|
||||||
|
await WebsocketThroughputLogger.receive(uncompressed_len, recv_len)
|
||||||
|
elif text_data:
|
||||||
|
uncompressed_len = len(text_data.encode("utf-8"))
|
||||||
|
await WebsocketThroughputLogger.receive(uncompressed_len)
|
||||||
|
|
||||||
|
if text_data:
|
||||||
|
await self.receive_json(json.loads(text_data), **kwargs)
|
||||||
|
|
||||||
|
async def send_json(self, content: Any, close: bool = False) -> None:
|
||||||
|
text_data = json.dumps(content)
|
||||||
|
bytes_data = None # type: ignore
|
||||||
|
|
||||||
|
b_text_data = text_data.encode("utf-8")
|
||||||
|
uncompressed_len = len(b_text_data)
|
||||||
|
|
||||||
|
if getattr(settings, "COMPRESSION", True):
|
||||||
|
compressed_data = lz4.frame.compress(b_text_data)
|
||||||
|
ratio = len(b_text_data) / len(compressed_data)
|
||||||
|
if ratio > 1:
|
||||||
|
bytes_data = compressed_data
|
||||||
|
text_data = None # type: ignore
|
||||||
|
await WebsocketThroughputLogger.send(uncompressed_len, len(bytes_data))
|
||||||
|
|
||||||
|
if not bytes_data:
|
||||||
|
await WebsocketThroughputLogger.send(uncompressed_len)
|
||||||
|
|
||||||
|
await self.send(text_data=text_data, bytes_data=bytes_data, close=close)
|
||||||
|
|
||||||
|
async def receive_json(self, content: str, **kwargs: Dict[str, Any]) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocollAsyncJsonWebsocketConsumer(AsyncCompressedJsonWebsocketConsumer):
|
||||||
"""
|
"""
|
||||||
Mixin for JSONWebsocketConsumers, that speaks the a special protocol.
|
Mixin for JSONWebsocketConsumers, that speaks the a special protocol.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def send_json(
|
async def send_json( # type: ignore
|
||||||
self,
|
self,
|
||||||
type: str,
|
type: str,
|
||||||
content: Any,
|
content: Any,
|
||||||
@ -77,7 +126,7 @@ class ProtocollAsyncJsonWebsocketConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
silence_errors=silence_errors,
|
silence_errors=silence_errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def receive_json(self, content: Any) -> None:
|
async def receive_json(self, content: Any) -> None: # type: ignore
|
||||||
"""
|
"""
|
||||||
Receives the json data, parses it and calls receive_content.
|
Receives the json data, parses it and calls receive_content.
|
||||||
"""
|
"""
|
||||||
|
@ -9,6 +9,7 @@ Django>=2.1,<2.3
|
|||||||
djangorestframework>=3.4,<3.10
|
djangorestframework>=3.4,<3.10
|
||||||
jsonfield2>=3.0,<3.1
|
jsonfield2>=3.0,<3.1
|
||||||
jsonschema>=3.0,<3.1
|
jsonschema>=3.0,<3.1
|
||||||
|
lz4>=2.1.6
|
||||||
mypy_extensions>=0.4,<0.5
|
mypy_extensions>=0.4,<0.5
|
||||||
PyPDF2>=1.26,<1.27
|
PyPDF2>=1.26,<1.27
|
||||||
roman>=2.0,<3.2
|
roman>=2.0,<3.2
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
|
from typing import Optional
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from channels.testing import WebsocketCommunicator
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
|
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
|
||||||
|
|
||||||
@ -20,6 +20,7 @@ from openslides.utils.websocket import WEBSOCKET_CHANGE_ID_TOO_HIGH
|
|||||||
|
|
||||||
from ...unit.utils.cache_provider import Collection1, Collection2, get_cachable_provider
|
from ...unit.utils.cache_provider import Collection1, Collection2, get_cachable_provider
|
||||||
from ..helpers import TConfig, TProjector, TUser
|
from ..helpers import TConfig, TProjector, TUser
|
||||||
|
from ..websocket import WebsocketCommunicator
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@ -45,7 +46,7 @@ async def prepare_element_cache(settings):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def get_communicator():
|
async def get_communicator():
|
||||||
communicator: WebsocketCommunicator = None
|
communicator: Optional[WebsocketCommunicator] = None
|
||||||
|
|
||||||
def get_communicator(query_string=""):
|
def get_communicator(query_string=""):
|
||||||
nonlocal communicator # use the outer communicator variable
|
nonlocal communicator # use the outer communicator variable
|
||||||
|
25
tests/integration/websocket.py
Normal file
25
tests/integration/websocket.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import lz4.frame
|
||||||
|
from channels.testing import WebsocketCommunicator as ChannelsWebsocketCommunicator
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketCommunicator(ChannelsWebsocketCommunicator):
|
||||||
|
"""
|
||||||
|
Implements decompression when receiving JSON data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def receive_json_from(self, timeout=1):
|
||||||
|
"""
|
||||||
|
Receives a JSON text frame or a compressed JSON bytes object, decompresses and decodes it
|
||||||
|
"""
|
||||||
|
payload = await self.receive_from(timeout)
|
||||||
|
if isinstance(payload, bytes):
|
||||||
|
# try to decompress the message
|
||||||
|
uncompressed_data = lz4.frame.decompress(payload)
|
||||||
|
text_data = uncompressed_data.decode("utf-8")
|
||||||
|
else:
|
||||||
|
text_data = payload
|
||||||
|
|
||||||
|
assert isinstance(text_data, str), "JSON data is not a text frame"
|
||||||
|
return json.loads(text_data)
|
Loading…
Reference in New Issue
Block a user