Incremental + Batched frontend state updates (#888)

* Update JS package versions.

* Forgot to remove a debugging log statement.

* Figured out the peerDependency npm issues... react-scripts thought it needed an old version of typescript, which we don't even need.

* Re-work state broadcasting mechanism to prevent excessive data transmission.

* Tidy up delta parsing, handle failure to serialize state diffs.

* Apply diffs in batches and use immer to apply them in a simple way.

* Update frontend build.

* Update stale docstrings.

* Rebuild frontend.

* Delete a file that wasn't being used.
This commit is contained in:
David Winings
2025-09-19 17:26:19 -07:00
committed by GitHub
parent 0ddf48af11
commit 116a60e404
19 changed files with 290 additions and 48 deletions
+25 -4
View File
@@ -1,9 +1,11 @@
import os
import orjson
import traceback
from deepdiff.helper import DELTA_VIEW
from qtpy.QtCore import QObject, Signal
from deepdiff import DeepDiff, extract
from deepdiff import DeepDiff, Delta, extract
from functools import partial
import shutil
import threading
@@ -15,7 +17,8 @@ from .Helpers.TSHDictHelper import deep_get, deep_set, deep_unset, deep_clone
from .SettingsManager import SettingsManager
class StateManagerSignals(QObject):
state_updated = Signal()
state_big_change = Signal()
state_updated = Signal(dict)
class StateManager:
lastSavedState = {}
@@ -23,6 +26,7 @@ class StateManager:
saveBlocked = 0
signals = StateManagerSignals()
changedKeys = []
deltaIndex = 0
lock = threading.RLock()
threads = []
@@ -64,13 +68,28 @@ class StateManager:
diff = DeepDiff(
StateManager.lastSavedState,
StateManager.state,
include_paths=StateManager.changedKeys
exclude_types=[type(None)],
include_paths=StateManager.changedKeys,
verbose_level=2, # Necessary to see values of added items.
)
delta = Delta(diff).to_flat_dicts()
# logger.debug(f"State diff length: {diff_count}")
if len(delta) > 100:
StateManager.deltaIndex += 1
StateManager.signals.state_big_change.emit()
elif len(delta) > 0:
try:
StateManager.deltaIndex += 1
StateManager.signals.state_updated.emit({
'delta_index': StateManager.deltaIndex,
'delta': Delta(diff).to_flat_dicts()
})
except TypeError:
logger.warning(f"Couldn't serialize diff. Changed Keys: {StateManager.changedKeys}")
StateManager.changedKeys = []
if len(diff) > 0:
StateManager.signals.state_updated.emit()
exportThread = threading.Thread(
target=partial(ExportAll, ref_diff=diff))
StateManager.threads.append(exportThread)
@@ -83,11 +102,13 @@ class StateManager:
try:
with open("./out/program_state.json", 'rb') as file:
StateManager.state = orjson.loads(file.read())
StateManager.signals.state_big_change.emit()
except FileNotFoundError:
pass
except Exception as e:
logger.error(traceback.format_exc())
StateManager.state = {}
StateManager.signals.state_big_change.emit()
StateManager.SaveState()
def Set(key: str, value):
+36 -1
View File
@@ -1,4 +1,5 @@
import html
import json
import os
import traceback
@@ -13,6 +14,7 @@ from flask_socketio import SocketIO, emit
import orjson
from loguru import logger
from .StateManager import StateManager
from .TSHWebServerActions import WebServerActions
from .TSHScoreboardManager import TSHScoreboardManager
from .TSHCommentaryWidget import TSHCommentaryWidget
@@ -23,6 +25,18 @@ import logging
log = logging.getLogger('socketio.server')
log.setLevel(logging.ERROR)
class SocketioJson:
def default(obj):
if isinstance(obj, type(type(1))):
return str(obj)
return obj
def dumps(*args, **kwargs):
return json.dumps(*args, **kwargs, default=SocketioJson.default)
def loads(*args, **kwargs):
return json.loads(*args, **kwargs)
class WebServer(QThread):
app = Flask(__name__, static_folder=os.path.curdir)
@@ -33,6 +47,7 @@ class WebServer(QThread):
# Uncomment to enable SocketIO logging (As logging is unuseful, we'll make this a dev flag)
# logger=logger,
async_mode='threading',
json=SocketioJson
)
app.config['CORS_HEADERS'] = 'Content-Type'
actions = None
@@ -45,6 +60,10 @@ class WebServer(QThread):
stageWidget=stageWidget,
commentaryWidget=commentaryWidget
)
StateManager.signals.state_updated.connect(WebServer.on_program_state_update)
StateManager.signals.state_big_change.connect(WebServer.ws_program_state)
self.host_name = "0.0.0.0"
self.port = SettingsManager.Get("general.webserver_port", 5000)
@@ -52,12 +71,28 @@ class WebServer(QThread):
def program_state():
return WebServer.actions.program_state()
@socketio.on('program-state-update')
def ws_program_state_update(message):
WebServer.ws_emit('program_state_update', {})
def on_program_state_update(changes):
if len(changes) > 0:
try:
WebServer.ws_emit('program_state_update', changes)
except TypeError:
logger.warning("Unserializable program state update")
# If we can't emit a diff, fall back to emitting the whole program
# state. Well-behaved listeners should discard their existing state
# and re-sync with us that way.
WebServer.ws_program_state()
@socketio.on('connect')
def ws_connect(message):
WebServer.ws_program_state(message)
@socketio.on('program-state')
def ws_program_state(message):
def ws_program_state(message=None):
WebServer.ws_emit('program_state', WebServer.actions.program_state())
@socketio.on_error_default
+5 -3
View File
@@ -34,7 +34,7 @@ class WebServerActions(QThread):
self.threadPool = QThreadPool()
def program_state(self):
return StateManager.state
return {'state': StateManager.state, 'delta_index': StateManager.deltaIndex}
def ruleset(self):
data = {}
@@ -400,12 +400,14 @@ class WebServerActions(QThread):
return str(self.scoreboard.GetScoreboard(scoreboard).lastSetSelected)
def get_sets(self, args):
provider = TSHTournamentDataProvider.instance.GetProvider()
if provider is None:
return []
if args.get('getFinished') is not None:
provider = TSHTournamentDataProvider.instance.GetProvider()
sets = provider.GetMatches(getFinished=True)
return sets
else:
provider = TSHTournamentDataProvider.instance.GetProvider()
sets = provider.GetMatches(getFinished=False)
return sets
-3
View File
@@ -851,9 +851,6 @@ class Window(QMainWindow):
TSHScoreboardManager.instance.signals.ScoreboardAmountChanged.connect(
self.ToggleTopOption)
StateManager.Unset("completed_sets")
StateManager.signals.state_updated.connect(
self.webserver.ws_program_state
)
DownloadLayoutsOnBoot()
+3 -3
View File
@@ -1,7 +1,7 @@
{
"files": {
"main.css": "/stage_strike_app/build/static/css/main.7107fe26.css",
"main.js": "/stage_strike_app/build/static/js/main.6231d541.js",
"main.js": "/stage_strike_app/build/static/js/main.2113032e.js",
"static/js/488.cce04f36.chunk.js": "/stage_strike_app/build/static/js/488.cce04f36.chunk.js",
"static/media/stage_gentlemans.svg": "/stage_strike_app/build/static/media/stage_gentlemans.0f86d7c03cfac3aa120b.svg",
"static/media/stage_dsr.svg": "/stage_strike_app/build/static/media/stage_dsr.b691229038cc875107cc.svg",
@@ -9,11 +9,11 @@
"static/media/stage_select.svg": "/stage_strike_app/build/static/media/stage_select.93f1de5f83321b0d15a0.svg",
"index.html": "/stage_strike_app/build/index.html",
"main.7107fe26.css.map": "/stage_strike_app/build/static/css/main.7107fe26.css.map",
"main.6231d541.js.map": "/stage_strike_app/build/static/js/main.6231d541.js.map",
"main.2113032e.js.map": "/stage_strike_app/build/static/js/main.2113032e.js.map",
"488.cce04f36.chunk.js.map": "/stage_strike_app/build/static/js/488.cce04f36.chunk.js.map"
},
"entrypoints": [
"static/css/main.7107fe26.css",
"static/js/main.6231d541.js"
"static/js/main.2113032e.js"
]
}
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="en"><head><base href="/stage_strike_app/build/"><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"/><meta charset="utf-8"/><link rel="icon" href="/stage_strike_app/build/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="TournamentStreamHelper Stage Strike App"/><link rel="apple-touch-icon" href="/stage_strike_app/build/logo192.png"/><link rel="manifest" href="/stage_strike_app/build/manifest.json"/><title>Stage Strike</title><script defer="defer" src="/stage_strike_app/build/static/js/main.6231d541.js"></script><link href="/stage_strike_app/build/static/css/main.7107fe26.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><base href="/stage_strike_app/build/"><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"/><meta charset="utf-8"/><link rel="icon" href="/stage_strike_app/build/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="TournamentStreamHelper Stage Strike App"/><link rel="apple-touch-icon" href="/stage_strike_app/build/logo192.png"/><link rel="manifest" href="/stage_strike_app/build/manifest.json"/><title>Stage Strike</title><script defer="defer" src="/stage_strike_app/build/static/js/main.2113032e.js"></script><link href="/stage_strike_app/build/static/css/main.7107fe26.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15 -3
View File
@@ -18,6 +18,7 @@
"@testing-library/user-event": "^14.6.1",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"immer": "^10.1.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.1",
@@ -9033,9 +9034,10 @@
}
},
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -13346,6 +13348,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-dev-utils/node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/react-dev-utils/node_modules/loader-utils": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz",
+1
View File
@@ -14,6 +14,7 @@
"@testing-library/user-event": "^14.6.1",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"immer": "^10.1.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.1",
@@ -1,4 +1,5 @@
import { createContext } from 'react';
import './backendDataTypes';
export const /** @type {Context<TSHState>} */ TSHStateContext = createContext(null);
export const /** @type {Context<TSHCharacterDb>} */ TSHCharacterContext = createContext(null);
@@ -113,6 +113,8 @@ export default React.forwardRef(function Player({teamId, teamKey, player}, ref)
expanded: true
});
// console.log(`Rendering player widget: `, player)
const playerId = `${teamId}-p-${teamKey}`;
const /** @type {TSHState} */ tshState = React.useContext(TSHStateContext);
const gameCodename = tshState?.game?.codename;
@@ -90,7 +90,6 @@ export default React.forwardRef(
playerRefs[teamKey] = React.createRef();
}
console.log(`Rendering player widget: `, player)
return (
<Player
key={`${teamId}-p-${teamKey}`}
@@ -1,9 +1,11 @@
import {styled, TextField as MuiTextField} from "@mui/material";
const TextField = styled(MuiTextField)(({theme}) => ({
'& label[data-shrink="false"]': {
color: theme.palette.text.disabled
}
}));
const TextField = styled(MuiTextField)(({theme}) =>
({
'& label[data-shrink="false"]': {
color: theme.palette.text.disabled
}
})
);
export default TextField;
+86 -18
View File
@@ -13,10 +13,21 @@ import CurrentSet from "./CurrentSet";
import UpcomingSets from "./UpcomingSets";
import {TSHStateContext, TSHCharacterContext, TSHPlayerDBContext} from "./Contexts";
import {Header} from "./Header";
import {produce as immer_produce} from "immer";
import {applyDeltas, combineDeltas} from "../stateDelta";
import {BACKEND_PORT} from "../env";
/**
* Main page for the scoreboard. This whole contraption is powered by TSH's python-side
* program state. In order to do that, we subscribe to updates that get sent out and update
* our state piecemeal. Each update has a number so that we can tell if our updates are stale
* or out of order and request a full state send-over.
*/
export default function ScoreboardPage(props) {
const [tshState, setTshState] = React.useState(null);
const [receivedDeltas, setReceivedDeltas] = React.useState([]);
const [maxAppliedDeltaIdx, setMaxAppliedDeltaIdx] = React.useState(-1);
const [loadingStatus, setLoadingStatus] = React.useState({
connectionError: false,
isLoading: true,
@@ -29,7 +40,7 @@ export default function ScoreboardPage(props) {
function connectToSocketIO() {
socket = io(`ws://${window.location.hostname}:${BACKEND_PORT}/`, {
transports: ['websocket', 'webtransport'],
timeout: 500,
timeout: 5000,
reconnectionDelay: 500,
reconnectionDelayMax: 1500
});
@@ -41,9 +52,28 @@ export default function ScoreboardPage(props) {
});
socket.on("program_state", data => {
console.log("TSH state update received ", data);
setTshState(data);
})
console.log("TSH state received ", data);
setMaxAppliedDeltaIdx(data['delta_index'])
setReceivedDeltas(receivedDeltas.filter(d => d.deltaIdx <= data['delta_index']))
setTshState(data['state']);
});
socket.on("program_state_update", deltaMessage => {
console.log("TSH state update received", deltaMessage);
const deltaIdx = deltaMessage['delta_index'];
const delta = deltaMessage['delta'];
if (deltaIdx < maxAppliedDeltaIdx) {
console.warn("Received out of order delta! Requesting new full state.");
socket.emit("program_state", {});
} else {
setReceivedDeltas((prevState) => immer_produce(prevState, (draft) => {
// Each delta that we receive is an array of delta objects.
for (let subdelta of delta) {
draft.push({deltaIdx, delta: subdelta});
}
}));
}
});
socket.on("playerdb", data => {
console.log("Player data received", data);
@@ -75,6 +105,37 @@ export default function ScoreboardPage(props) {
})
}
React.useEffect(() => {
const intervalId = setInterval(() => {
let sortedDeltas = receivedDeltas.toSorted((a, b) => a.deltaIdx - b.deltaIdx);
const staleDeltas = sortedDeltas.filter((d) => d.deltaIdx < maxAppliedDeltaIdx);
sortedDeltas = sortedDeltas.filter((d) => d.deltaIdx >= maxAppliedDeltaIdx);
if (staleDeltas.length > 0) {
console.warn("Skipping applying stale deltas...", staleDeltas);
}
if (sortedDeltas.length > 0) {
console.log("Applying deltas: ", combineDeltas(sortedDeltas.map(d => d.delta)));
setTshState((prevState) => {
const newState = immer_produce(prevState, (draftState) => {
try {
applyDeltas(draftState, sortedDeltas.map((d) => d.delta));
} catch (e) {
console.warn("Could not apply deltas.", e);
}
});
return newState;
});
setMaxAppliedDeltaIdx(sortedDeltas[sortedDeltas.length-1].deltaIdx);
setReceivedDeltas([]);
}
}, 1000);
return () => clearInterval(intervalId);
}, [receivedDeltas, maxAppliedDeltaIdx]);
React.useEffect(() => {
window.title = `TSH ${i18n.t("scoreboard")}`;
connectToSocketIO();
@@ -86,18 +147,25 @@ export default function ScoreboardPage(props) {
// want this to only run once when the component is loaded.
let body;
const connectionError = (
<Paper key="connection_error" elevation={2} sx={{padding: '8px'}}>
<div>{i18n.t("failed_to_connect")}</div>
</Paper>
);
const loading = (
<Paper key="loading" elevation={2} sx={{padding: '8px'}}>
<div>{i18n.t("loading")}</div>
</Paper>
);
const onSelectedSetChanged = React.useCallback(() => {
setLoadingStatus({isLoading: true, connectionError: false})
}, []);
if (!!loadingStatus.connectionError) {
body = (
<Paper elevation={2} sx={{padding: '8px'}}>
<div>{i18n.t("failed_to_connect")}</div>
</Paper>
);
} else if (tshState === null || characters === null || playerDb === null) {
body = (
<Paper elevation={2} sx={{padding: '8px'}}>
<div>{i18n.t("loading")}</div>
</Paper>
);
body = connectionError;
} else if (!tshState || !characters || !playerDb) {
body = loading;
} else {
body = (
// Extra margin at the bottom allows for mobile users to see the bottom of the page better.
@@ -109,11 +177,11 @@ export default function ScoreboardPage(props) {
>
<Stack gap={4} marginBottom={24}>
<CurrentSet/>
<UpcomingSets onSelectedSetChanged={() => {setLoadingStatus({isLoading: true, connectionError: false})}}/>
<UpcomingSets onSelectedSetChanged={onSelectedSetChanged}/>
</Stack>
</Box>
</>
)
);
}
return (
@@ -134,5 +202,5 @@ export default function ScoreboardPage(props) {
</TSHCharacterContext.Provider>
</TSHStateContext.Provider>
</Box>
);
)
}
-2
View File
@@ -1,5 +1,3 @@
import React from 'react';
export const BACKEND_PORT = process.env.NODE_ENV === 'production'
? window.location.port
: 5000;
+104
View File
@@ -0,0 +1,104 @@
/**
* This module contains data-modification functions for working with diffs generated via
* the python DeepDiff library. The idea is that we have fairly heavily nested state
* python-side, and we will be transmitted diffs as that state changes, to allow us to
* avoid having to receive the entire state from the StateManager every time the state changes.
*
* Further, since we are using React, react requires an entirely new object to be created
* on state updates, which is a large amount of memory to burn to change a single key in
* an object 6 layers of nesting down. To solve this, we rely on the `immer` library,
* which knows how to create "new" objects without re-allocating unchanged memory. Since React state
* objects don't change, this is both safe and considered good practice. Basically
* the way it works is that immer creates a "draft" object, which is very similar to a
* recording mock used for unit tests. Once you modify the draft object, immer
* will create a new object that shares memory with the old object where possible.
*
* It's worth mentioning that we depend on using the **Delta** portion of python's DeepDiff library.
* using the deltas makes it specify every changed scalar rather than grouping things into
* objects that get added, which complicates things dramatically when trying to batch updates.
*/
/** @typedef {{
* action: DeltaOpType,
* path: (string|number)[]
* type: string
* value: any
* }} Delta
*/
/**
* @typedef {(
* "type_changes"
* | "values_changed"
* | "dictionary_item_added"
* | "dictionary_item_removed"
* | "iterable_item_added"
* | "iterable_item_removed"
* | "attribute_added"
* | "attribute_removed"
* | "set_item_added"
* | "set_item_removed"
* | "repetition_change"
* )} DeltaOpType
*/
/**
* Takes a single delta operation, and modifies the data object.
*
* @param {Object} data
* @param {Delta} delta
*/
export function applyDelta(data, delta) {
const pathPieces = delta.path;
/** @type {DeltaOpType} */ const deltaOp = delta.action;
const newValue = delta.value;
const lastPiece = pathPieces[pathPieces.length-1];
let currentData = data;
for (let i = 0; i < pathPieces.length-1; i += 1) {
if (!currentData.hasOwnProperty(pathPieces[i])) {
currentData[pathPieces[i]] = {};
}
currentData = currentData[pathPieces[i]];
}
if (deltaOp === 'dictionary_item_removed') {
if (currentData.hasOwnProperty(lastPiece)) {
delete currentData[lastPiece];
} else {
console.warn(`Couldn't find data to delete for path: ${pathPieces}`);
}
} else {
currentData[lastPiece] = newValue;
}
}
/**
* @param {Object} data
* @param {Delta[]} deltas python deep-diff delta object.
*/
export function applyDeltas(data, deltas) {
for (let delta of deltas) {
applyDelta(data, delta);
}
return data;
}
/**
* Useful function for printing and debugging.
*/
export function combineDeltas(/** Delta[] */ deltas) {
const megaDiff = {};
for (let delta of deltas) {
if (!megaDiff.hasOwnProperty(delta.action)) {
megaDiff[delta.action] = {};
}
megaDiff[delta.action][delta.path.join('.')] = {value: delta.value, type: delta.type};
}
return megaDiff;
}