From 6e61a7ecf9bc8dde980e51da6e762f603f5fdfd4 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 27 Apr 2024 22:37:45 +0200 Subject: [PATCH] refactor: runtime side effects --- apps/server/src/services/TimerService.ts | 122 +------------- .../integration-service/IntegrationService.ts | 11 +- .../runtime-service/RuntimeService.ts | 158 ++++++++++++++++-- 3 files changed, 148 insertions(+), 143 deletions(-) diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts index 5f926b257..03adb868e 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/TimerService.ts @@ -1,27 +1,15 @@ -import { OntimeEvent, Playback, RuntimeStore } from 'ontime-types'; +import { OntimeEvent } from 'ontime-types'; -import { deepEqual } from 'fast-equals'; - -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 type { UpdateResult } from '../stores/runtimeState.js'; /** * Service manages Ontime's main timer - * It is responsible for streaming the data to the event store */ export class TimerService { 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; @@ -39,10 +27,6 @@ export class TimerService { 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; @@ -51,7 +35,6 @@ export class TimerService { }, TimerService._refreshInterval); } - @broadcastResult start() { if (!runtimeState.start()) { return false; @@ -63,7 +46,6 @@ export class TimerService { return true; } - @broadcastResult pause() { if (!runtimeState.pause()) { return false; @@ -74,7 +56,6 @@ export class TimerService { return true; } - @broadcastResult stop() { if (!runtimeState.stop()) { return false; @@ -89,7 +70,6 @@ export class TimerService { * Adds time to running timer by given amount * @param {number} amount */ - @broadcastResult addTime(amount: number): boolean { if (!runtimeState.addTime(amount)) { return false; @@ -105,7 +85,6 @@ export class TimerService { /** * Update the app at regular intervals */ - @broadcastResult update() { const updateResult = runtimeState.update(); // pass the result to the parent @@ -116,7 +95,6 @@ export class TimerService { * Loads roll information into timer service * @param {OntimeEvent[]} rundown -- list of events to run */ - @broadcastResult roll(rundown: OntimeEvent[]) { runtimeState.roll(rundown); } @@ -126,99 +104,3 @@ export class TimerService { clearTimeout(this.endCallback); } } - -function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { - 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(); - - // 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 || - state.clock - TimerService.previousUpdate >= TimerService._updateInterval; - - // some changes need an immediate update - const hasNewLoaded = state.eventNow?.id !== TimerService.previousState?.eventNow?.id; - - const hasSkippedBack = state.clock < TimerService.previousUpdate; - const justStarted = !TimerService.previousState?.timer; - const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback; - const hasImmediateChanges = hasNewLoaded || hasSkippedBack || justStarted || hasChangedPlayback; - - if (hasChangedPlayback) { - eventStore.set('onAir', state.timer.playback !== Playback.Stop); - } - - if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(TimerService.previousState?.timer, state.timer))) { - eventStore.set('timer', state.timer); - TimerService.previousState.timer = { ...state.timer }; - } - - if (hasChangedPlayback || (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) { - const previous = TimerService.previousState?.[eventKey]; - const now = state[eventKey]; - - // if there was nothing, and there is nothing, noop - if (!previous?.id && !now?.id) { - return; - } - - // if load status changed, save new - if (previous?.id !== now?.id) { - storeKey(eventKey); - return; - } - - // maybe the event itself has changed - if (!deepEqual(TimerService.previousState?.[eventKey], state[eventKey])) { - storeKey(eventKey); - return; - } - - function storeKey(eventKey: keyof RuntimeStore) { - 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, - }); - } - - return result; - }; - - return descriptor; -} diff --git a/apps/server/src/services/integration-service/IntegrationService.ts b/apps/server/src/services/integration-service/IntegrationService.ts index 40ac2b0da..4c8e59086 100644 --- a/apps/server/src/services/integration-service/IntegrationService.ts +++ b/apps/server/src/services/integration-service/IntegrationService.ts @@ -1,7 +1,7 @@ import { LogOrigin } from 'ontime-types'; import IIntegration, { TimerLifeCycleKey } from './IIntegration.js'; -import { getState } from '../../stores/runtimeState.js'; +import { eventStore } from '../../stores/EventStore.js'; import { logger } from '../../classes/Logger.js'; class IntegrationService { @@ -20,14 +20,7 @@ class IntegrationService { } dispatch(action: TimerLifeCycleKey) { - /** - * We currently get the state from the runtimeState store - * This solves an issue where the state is not updated until after the integrations have ran - * The workaround solves the issue with the tradeoff of - * - we do not have access to data outside runtimeState (eg: messages or auxtimers) - * - we couple the integrationService to runtimeState - */ - const state = getState(); + const state = eventStore.poll(); this.integrations.forEach((integration) => { integration.dispatch(action, state); }); diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index fc2e28850..6b007b446 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -1,12 +1,16 @@ -import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle } from 'ontime-types'; +import { EndAction, LogOrigin, OntimeEvent, Playback, RuntimeStore, TimerLifeCycle } from 'ontime-types'; import { millisToString, validatePlayback } from 'ontime-utils'; -import { TimerService } from '../TimerService.js'; +import { deepEqual } from 'fast-equals'; + import { logger } from '../../classes/Logger.js'; -import { RestorePoint } from '../RestoreService.js'; - import * as runtimeState from '../../stores/runtimeState.js'; +import type { RuntimeState } from '../../stores/runtimeState.js'; +import { timerConfig } from '../../config/config.js'; +import { eventStore } from '../../stores/EventStore.js'; +import { TimerService } from '../TimerService.js'; +import { RestorePoint, restoreService } from '../RestoreService.js'; import { findNext, findPrevious, @@ -16,7 +20,6 @@ import { getPlayableEvents, } from '../rundown-service/rundownUtils.js'; import { integrationService } from '../integration-service/IntegrationService.js'; -import { timerConfig } from '../../config/config.js'; /** * Service manages runtime status of app @@ -26,11 +29,24 @@ class RuntimeService { private eventTimer: TimerService | null = null; private lastOnUpdate = -1; + /** last time we updated the socket */ + static previousUpdate: number; + /** last known state */ + static previousState: RuntimeState; + + constructor() { + RuntimeService.previousUpdate = -1; + RuntimeService.previousState = {} as RuntimeState; + } + /** Checks result of an update and notifies integrations as needed */ + @broadcastResult checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) { const newState = runtimeState.getState(); if (hasTimerFinished) { - integrationService.dispatch(TimerLifeCycle.onFinish); + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onFinish); + }); // handle end action if there was a timer playing // actions are added to the queue stack to ensure that the order of operations is maintained @@ -49,10 +65,14 @@ class RuntimeService { if (newState.clock - this.lastOnUpdate >= timerConfig.notificationRate) { const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play; if (hasRunningTimer) { - integrationService.dispatch(TimerLifeCycle.onUpdate); + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onUpdate); + }); } + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onClock); + }); - integrationService.dispatch(TimerLifeCycle.onClock); this.lastOnUpdate = newState.clock; } @@ -186,6 +206,7 @@ class RuntimeService { * @param {OntimeEvent} event * @return {boolean} success - whether an event was loaded */ + @broadcastResult loadEvent(event: OntimeEvent): boolean { if (event.skip) { logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`); @@ -196,10 +217,10 @@ class RuntimeService { const success = runtimeState.load(event, timedEvents); if (success) { - // TODO: dispatch should happen after store update - // currently store update is handled in TimerService only - integrationService.dispatch(TimerLifeCycle.onLoad); logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onLoad); + }); } return success; } @@ -325,6 +346,7 @@ class RuntimeService { /** * Starts playback on selected event */ + @broadcastResult start(): boolean { const state = runtimeState.getState(); const canStart = validatePlayback(state.timer.playback).start; @@ -335,7 +357,9 @@ class RuntimeService { const didStart = this.eventTimer?.start() ?? false; logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`); if (didStart) { - integrationService.dispatch(TimerLifeCycle.onStart); + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onStart); + }); } return didStart; } @@ -367,6 +391,7 @@ class RuntimeService { /** * Pauses playback on selected event */ + @broadcastResult pause() { const state = runtimeState.getState(); const canPause = validatePlayback(state.timer.playback).pause; @@ -376,12 +401,15 @@ class RuntimeService { this.eventTimer?.pause(); const newState = state.timer.playback; logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); - integrationService.dispatch(TimerLifeCycle.onPause); + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onPause); + }); } /** * Stops timer and unloads any events */ + @broadcastResult stop(): boolean { const state = runtimeState.getState(); const canStop = validatePlayback(state.timer.playback).stop; @@ -392,7 +420,10 @@ class RuntimeService { if (didStop) { const newState = state.timer.playback; logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`); - integrationService.dispatch(TimerLifeCycle.onStop); + process.nextTick(() => { + integrationService.dispatch(TimerLifeCycle.onStop); + }); + return true; } return false; @@ -401,6 +432,7 @@ class RuntimeService { /** * Reloads current event */ + @broadcastResult reload() { const state = runtimeState.getState(); if (state.eventNow) { @@ -411,6 +443,7 @@ class RuntimeService { /** * Sets playback to roll */ + @broadcastResult roll() { const beforeState = runtimeState.getState(); const canRoll = validatePlayback(beforeState.timer.playback).roll; @@ -435,6 +468,7 @@ class RuntimeService { * @description resume playback state given a restore point * @param restorePoint */ + @broadcastResult resume(restorePoint: RestorePoint) { const { selectedEventId, playback } = restorePoint; if (playback === Playback.Roll) { @@ -470,3 +504,99 @@ class RuntimeService { } export const runtimeService = new RuntimeService(); + +function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { + 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(); + + // 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 < RuntimeService.previousUpdate || + state.clock - RuntimeService.previousUpdate >= timerConfig.notificationRate; + + // some changes need an immediate update + const hasNewLoaded = state.eventNow?.id !== RuntimeService.previousState?.eventNow?.id; + + const hasSkippedBack = state.clock < RuntimeService.previousUpdate; + const justStarted = !RuntimeService.previousState?.timer; + const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback; + const hasImmediateChanges = hasNewLoaded || hasSkippedBack || justStarted || hasChangedPlayback; + + if (hasChangedPlayback) { + eventStore.set('onAir', state.timer.playback !== Playback.Stop); + } + + if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(RuntimeService.previousState?.timer, state.timer))) { + eventStore.set('timer', state.timer); + RuntimeService.previousState.timer = { ...state.timer }; + } + + if (hasChangedPlayback || (isTimeToUpdate && !deepEqual(RuntimeService.previousState?.runtime, state.runtime))) { + eventStore.set('runtime', state.runtime); + RuntimeService.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) { + RuntimeService.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.RuntimeState) { + const previous = RuntimeService.previousState?.[eventKey]; + const now = state[eventKey]; + + // if there was nothing, and there is nothing, noop + if (!previous?.id && !now?.id) { + return; + } + + // if load status changed, save new + if (previous?.id !== now?.id) { + storeKey(eventKey); + return; + } + + // maybe the event itself has changed + if (!deepEqual(RuntimeService.previousState?.[eventKey], state[eventKey])) { + storeKey(eventKey); + return; + } + + function storeKey(eventKey: keyof RuntimeStore) { + eventStore.set(eventKey, state[eventKey]); + RuntimeService.previousState[eventKey] = { ...state[eventKey] }; + } + } + + // Helper function to save the restore state + function saveRestoreState(state: runtimeState.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, + }); + } + + return result; + }; + + return descriptor; +}