refactor: partial state updates (#780)

* refactor: partial state updates

* refactor: less calls to restore

* refactor: add error stack trace to log
This commit is contained in:
Carlos Valente
2024-02-19 21:31:27 +01:00
committed by GitHub
parent c890251dad
commit 7ff75835ff
22 changed files with 523 additions and 361 deletions
+126 -77
View File
@@ -1,52 +1,88 @@
import { EndAction, OntimeEvent, Playback, TimerLifeCycle } from 'ontime-types';
import { OntimeEvent, RuntimeStore } from 'ontime-types';
import { deepEqual } from 'fast-equals';
import * as runtimeState from '../stores/runtimeState.js';
import { integrationService } from './integration-service/IntegrationService.js';
import { eventStore } from '../stores/EventStore.js';
import * as runtimeState from '../stores/runtimeState.js';
import type { RuntimeState, UpdateResult } from '../stores/runtimeState.js';
import { restoreService } from './RestoreService.js';
import { runtimeService } from './runtime-service/RuntimeService.js';
import { getPlayableEvents } from './rundown-service/RundownService.js';
/**
* Service manages Ontime's main timer
* It is responsible for streaming the data to the event store
*/
export class TimerService {
private _interval: NodeJS.Timer;
private _updateInterval: number;
private _refreshInterval: number;
private readonly _interval: NodeJS.Timer;
/** how often we update the socket */
static _updateInterval: number;
/** how often we recalculate */
static _refreshInterval: number;
/** last time we updated the socket */
static previousUpdate: number;
/** last known state */
static previousState: RuntimeState;
/** when timer will be finished */
private endCallback: NodeJS.Timer;
private onUpdateCallback: (updateResult: UpdateResult) => void;
/**
* @constructor
* @param {number} [timerConfig.refresh]
* @param {number} [timerConfig.updateInterval]
* @param {number} [timerConfig.refresh] how often we recalculate
* @param {number} [timerConfig.updateInterval] how often we update the socket
* @param {function} [timerConfig.onUpdateCallback] how often we update the socket
*/
constructor(timerConfig: { refresh: number; updateInterval: number }) {
this._refreshInterval = timerConfig.refresh;
this._updateInterval = timerConfig.updateInterval;
this._interval = setInterval(this.update, 32);
constructor(timerConfig: {
refresh: number;
updateInterval: number;
onUpdateCallback: (updateResult: UpdateResult) => void;
}) {
TimerService.previousUpdate = -1;
TimerService.previousState = {} as RuntimeState;
TimerService._updateInterval = timerConfig.updateInterval;
TimerService._refreshInterval = timerConfig.refresh;
this.onUpdateCallback = timerConfig.onUpdateCallback;
this._interval = setInterval(() => {
this.update();
this.onUpdateCallback;
}, TimerService._updateInterval);
}
@broadcastResult
start() {
// TODO: when we start a timer, we schedule an update to its expected end - 16ms
// we need to cancel this timer on pause, stop and addTime
if (runtimeState.start()) {
integrationService.dispatch(TimerLifeCycle.onStart);
if (!runtimeState.start()) {
return false;
}
const state = runtimeState.getState();
this.endCallback = setTimeout(this.update, state.timer.expectedFinish);
return true;
}
@broadcastResult
pause() {
if (runtimeState.pause()) {
integrationService.dispatch(TimerLifeCycle.onPause);
if (!runtimeState.pause()) {
return false;
}
// cancel end callback
clearTimeout(this.endCallback);
return true;
}
@broadcastResult
stop() {
if (runtimeState.stop()) {
integrationService.dispatch(TimerLifeCycle.onStop);
if (!runtimeState.stop()) {
return false;
}
// cancel end callback
clearTimeout(this.endCallback);
return true;
}
/**
@@ -55,62 +91,40 @@ export class TimerService {
*/
@broadcastResult
addTime(amount: number): boolean {
return runtimeState.addTime(amount);
if (!runtimeState.addTime(amount)) {
return false;
}
// renew end callback
clearTimeout(this.endCallback);
const state = runtimeState.getState();
this.endCallback = setTimeout(this.update, state.timer.expectedFinish);
return true;
}
/**
* Update the app at regular intervals
* @param {boolean} force whether we should force a broadcast of state
*/
@broadcastResult
update(force = false) {
const { didUpdate, doRoll, isFinished, shouldNotify } = runtimeState.update(force, this._updateInterval);
if (didUpdate && shouldNotify) {
// TODO: can we distinguish between a clock update and a timer update?
integrationService.dispatch(TimerLifeCycle.onUpdate);
}
update() {
const updateResult = runtimeState.update();
if (doRoll) {
// TODO: escalate to parent
const rundown = getPlayableEvents();
runtimeState.roll(rundown);
}
if (isFinished) {
integrationService.dispatch(TimerLifeCycle.onFinish);
const newState = runtimeState.getState();
// handle end action if there was a timer playing
if (newState.timer.playback === Playback.Play && newState.eventNow) {
if (newState.eventNow.endAction === EndAction.Stop) {
runtimeState.stop();
} else if (newState.eventNow.endAction === EndAction.LoadNext) {
// we need to delay here to put this action in the queue stack. otherwise it won't be executed properly
setTimeout(runtimeState.loadNext, 0);
} else if (newState.eventNow.endAction === EndAction.PlayNext) {
// TODO: avoid calling the runtime service here
runtimeService.startNext();
}
}
}
// pass the result to the parent
this.onUpdateCallback(updateResult);
}
/**
* Loads roll information into timer service
* @throws {Error} if rundown is empty
* @param {OntimeEvent[]} rundown -- list of events to run
*/
@broadcastResult
roll(rundown: OntimeEvent[]) {
if (rundown.length === 0) {
throw new Error('No events found');
}
runtimeState.roll(rundown);
}
shutdown() {
clearInterval(this._interval);
clearTimeout(this.endCallback);
}
}
@@ -118,29 +132,64 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
// call the original method and get the state
const result = originalMethod.apply(this, args);
const state = runtimeState.getState();
// TODO: compare datasets to see what needs to be emitted
eventStore.batchSet({
clock: state.clock,
eventNow: state.eventNow,
publicEventNow: state.publicEventNow,
eventNext: state.eventNext,
publicEventNext: state.publicEventNext,
runtime: state.runtime,
timer: state.timer,
});
// we do the comparison by explicitly for each property
// to apply custom logic for different datasets
// some of the data, we only update at intervals
const isTimeToUpdate = state.clock - TimerService.previousUpdate >= TimerService._updateInterval;
// some changes need an immediate update
const hasSkippedBack = state.clock < TimerService.previousUpdate;
const justStarted = !TimerService.previousState?.timer;
const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback;
const hasImmediateChanges = hasSkippedBack || justStarted || hasChangedPlayback;
if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(TimerService.previousState?.timer, state.timer))) {
eventStore.set('timer', state.timer);
TimerService.previousState.timer = { ...state.timer };
}
if (isTimeToUpdate && !deepEqual(TimerService.previousState?.runtime, state.runtime)) {
eventStore.set('runtime', state.runtime);
TimerService.previousState.runtime = { ...state.runtime };
}
// Update the events if they have changed
updateEventIfChanged('eventNow', state);
updateEventIfChanged('publicEventNow', state);
updateEventIfChanged('eventNext', state);
updateEventIfChanged('publicEventNext', state);
if (isTimeToUpdate) {
TimerService.previousUpdate = state.clock;
eventStore.set('clock', state.clock);
saveRestoreState(state);
}
// Helper function to update an event if it has changed
function updateEventIfChanged(eventKey: keyof RuntimeStore, state: RuntimeState) {
if (!deepEqual(TimerService.previousState?.[eventKey], state[eventKey])) {
eventStore.set(eventKey, state[eventKey]);
TimerService.previousState[eventKey] = { ...state[eventKey] };
}
}
// Helper function to save the restore state
function saveRestoreState(state: RuntimeState) {
restoreService.save({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt,
firstStart: state.runtime.actualStart,
});
}
// we write to restore service if the underlying data changes
restoreService.save({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt,
firstStart: state.runtime.actualStart,
});
return result;
};