From 022a58e14200c6392fc51cc8cd2ff681c01bc5e9 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Sun, 10 Aug 2025 18:45:47 +0200 Subject: [PATCH] refactor: runtime service (#1722) * refactor: trim type * refactor: update runtime service data push logic * keep using just deep * reword didDependencyUpdate * remove dulicate --- .../runtime-service/RuntimeService.ts | 139 +++++++----------- .../__tests__/rundownService.utils.test.ts | 71 --------- .../runtime-service/rundownService.utils.ts | 98 ++++++++++-- apps/server/src/stores/runtimeState.ts | 10 +- .../runtime/CurrentGroupState.type.ts | 9 +- .../definitions/runtime/RuntimeStore.type.ts | 6 +- packages/types/src/index.ts | 2 +- 7 files changed, 145 insertions(+), 190 deletions(-) delete mode 100644 apps/server/src/services/runtime-service/__tests__/rundownService.utils.test.ts diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 529700991..b2b9c730a 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -33,9 +33,12 @@ import { findNextPlayableWithCue, findPreviousPlayableId, getEventAtIndex, - getForceUpdate, getShouldClockUpdate, + getShouldFlagUpdate, + getShouldGroupUpdate, + getShouldRuntimeUpdate, getShouldTimerUpdate, + isNewSecond, } from './rundownService.utils.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; @@ -141,7 +144,7 @@ class RuntimeService { } // 4. find if we need to update the timer - const shouldUpdateTimer = getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current); + const shouldUpdateTimer = isNewSecond(this.lastIntegrationTimerValue, newState.timer.current); if (shouldUpdateTimer) { process.nextTick(() => { triggerAutomations(TimerLifeCycle.onUpdate, newState); @@ -659,7 +662,6 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert // call the original method and get the state const result = originalMethod.apply(this, args); const state = runtimeState.getState(); - const batch = eventStore.createBatch(); // we do the comparison by explicitly for each property @@ -680,91 +682,63 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert // combine all big changes const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback || offsetModeChanged; - // we would like the wall clock to tick on a regular rate - const normalClockUpdate = - getShouldClockUpdate(RuntimeService.previousClockUpdate, state.clock) || - getForceUpdate(RuntimeService.previousClockUpdate, state.clock); + // clock has changed by a second or more + const updateClock = getShouldClockUpdate(RuntimeService.previousState.clock, state.clock); + if (updateClock) { + batch.add('clock', state.clock); + RuntimeService.previousState.clock = state.clock; + } - /** - * Timer should be updated if - * - big changes - * - notification rate has been exceeded - * - the timer has rolled over into the next UI display unit - * - * Then check if there is actually a change in the data - */ - const shouldUpdateTimer = - (hasImmediateChanges || - getForceUpdate(RuntimeService.previousTimerUpdate, state.clock) || - getShouldTimerUpdate(RuntimeService.previousTimerValue, state.timer.current)) && - !deepEqual(RuntimeService.previousState?.timer, state.timer); - - /** - * Runtime should be updated if - * - clock tick - * - big changes - * - the timer is updating so runtime also updates to keep them in sync ??? - * - notification rate has been exceeded - * - * Then check if there is actually a change in the data - */ - const shouldRuntimeUpdate = - (normalClockUpdate || - hasImmediateChanges || - shouldUpdateTimer || - getForceUpdate(RuntimeService.previousRuntimeUpdate, state.clock)) && - !deepEqual(RuntimeService.previousState?.runtime, state.runtime); - - // TODO: the value shows up one tick to late - const shouldGroupUpdate = - !deepEqual(RuntimeService?.previousState.groupNow, state.groupNow) || - RuntimeService?.previousState.groupNext !== state.groupNext; - - // TODO: the value shows up one tick to late - const shouldNextFlagUpdate = !deepEqual(RuntimeService?.previousState?.nextFlag, state.nextFlag); - - /** - * Many other values are calculated based on the clock - * so if any of them are updated we also need to send the clock - * in case nothing else is updating the clock will be updated at the notification rate - */ - const shouldUpdateClock = shouldRuntimeUpdate || shouldGroupUpdate || normalClockUpdate; - - // Now we set all the updates on the eventstore and update the previous value - if (shouldUpdateTimer) { + // if any values have changed, values that have the possibility to tick are updated when the seconds roll over + const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer); + if (updateTimer) { batch.add('timer', state.timer); RuntimeService.previousTimerUpdate = state.clock; RuntimeService.previousTimerValue = state.timer.current; RuntimeService.previousState.timer = { ...state.timer }; } - if (shouldRuntimeUpdate) { + // if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate` + const updateRuntime = getShouldRuntimeUpdate( + RuntimeService.previousState?.runtime, + state.runtime, + updateClock || hasImmediateChanges, + ); + if (updateRuntime) { batch.add('runtime', state.runtime); RuntimeService.previousRuntimeUpdate = state.clock; RuntimeService.previousState.runtime = structuredClone(state.runtime); } - if (shouldGroupUpdate) { + // if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate` + const updateGroupNow = getShouldGroupUpdate( + RuntimeService.previousState.groupNow, + state.groupNow, + updateClock || hasImmediateChanges, + ); + if (updateGroupNow) { batch.add('groupNow', state.groupNow); - batch.add('groupNext', state.groupNext); RuntimeService.previousState.groupNow = structuredClone(state.groupNow); + } + + // next group is just a simple string or null compare + const updateGroupNext = RuntimeService.previousState.groupNext !== state.groupNext; + if (updateGroupNext) { + batch.add('groupNext', state.groupNext); RuntimeService.previousState.groupNext = structuredClone(state.groupNext); } - if (shouldNextFlagUpdate) { + // if any values have changed, values that have the possibility to tick are modulated by `hasClockUpdate` + const updateFlag = getShouldFlagUpdate( + RuntimeService.previousState.nextFlag, + state.nextFlag, + updateClock || hasImmediateChanges, + ); + if (updateFlag) { batch.add('nextFlag', state.nextFlag); RuntimeService.previousState.nextFlag = structuredClone(state.nextFlag); } - if (hasImmediateChanges) { - saveRestoreState(state); - } - - if (shouldUpdateClock) { - RuntimeService.previousClockUpdate = state.clock; - batch.add('clock', state.clock); - } - // Update the events if they have changed updateEventIfChanged('eventNow', state); updateEventIfChanged('eventNext', state); @@ -775,31 +749,22 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert const now = state[eventKey]; // if there was nothing, and there is nothing, noop - if (!previous?.id && !now?.id) { - return; - } + if (!previous?.id && !now?.id) return; - // if load status changed, save new - if (previous?.id !== now?.id) { - storeKey(eventKey); - return; - } + const eventChanged = + // if load status changed, save new + previous?.id !== now?.id || + // maybe the event itself has changed + !deepEqual(RuntimeService.previousState?.[eventKey], state[eventKey]); - // maybe the event itself has changed - if (!deepEqual(RuntimeService.previousState?.[eventKey], state[eventKey])) { - storeKey(eventKey); - return; - } + if (!eventChanged) return; - function storeKey(eventKey: RuntimeStateEventKeys) { - batch.add(eventKey, state[eventKey]); - // @ts-expect-error -- not sure how to type this in a sane way - RuntimeService.previousState[eventKey] = { ...state[eventKey] }; - } + batch.add(eventKey, state[eventKey]); + RuntimeService.previousState[eventKey] = structuredClone(state[eventKey]); } - // Helper function to save the restore state - function saveRestoreState(state: runtimeState.RuntimeState) { + // save the restore state + if (hasImmediateChanges) { restoreService .save({ playback: state.timer.playback, diff --git a/apps/server/src/services/runtime-service/__tests__/rundownService.utils.test.ts b/apps/server/src/services/runtime-service/__tests__/rundownService.utils.test.ts deleted file mode 100644 index 2a8328b8f..000000000 --- a/apps/server/src/services/runtime-service/__tests__/rundownService.utils.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { MILLIS_PER_MINUTE } from 'ontime-utils'; -import { getShouldClockUpdate, getShouldTimerUpdate } from '../rundownService.utils.js'; - -beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(0); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('getShouldClockUpdate()', () => { - it('should return true when we slid forwards', () => { - const previousUpdate = Date.now(); // 2 seconds ago - const now = Date.now() + 2000; - const result = getShouldClockUpdate(previousUpdate, now); - expect(result).toBe(true); - }); - - it('should return true when we slid backwards', () => { - const previousUpdate = Date.now() + 2000; - const now = Date.now(); // 2 seconds ago - const result = getShouldClockUpdate(previousUpdate, now); - expect(result).toBe(true); - }); - - it('should return true when clock is a second ahead', () => { - const previousUpdate = MILLIS_PER_MINUTE - 100; - const now = MILLIS_PER_MINUTE; - const result = getShouldClockUpdate(previousUpdate, now); - expect(result).toBe(true); - }); - - it('should return false when clock is not a second ahead and force update is not required', () => { - const previousUpdate = Date.now(); - const now = Date.now() + 32; - const result = getShouldClockUpdate(previousUpdate, now); - expect(result).toBe(false); - }); -}); - -describe('getShouldTimerUpdate', () => { - it('should return false when currentValue is null', () => { - const previousValue = 0; - const currentValue = null; - const result = getShouldTimerUpdate(previousValue, currentValue); - expect(result).toBe(false); - }); - - it('should return true when timer is a second ahead', () => { - const previousValue = 6500; - const currentValue = 5000; - const result = getShouldTimerUpdate(previousValue, currentValue); - expect(result).toBe(true); - }); - - it('should return false when timer is not a second ahead', () => { - const previousValue = 5500; - const currentValue = 5200; - const result = getShouldTimerUpdate(previousValue, currentValue); - expect(result).toBe(false); - }); - - it('timer value is ceiled', () => { - const previousValue = 5001; // 6 - const currentValue = 4999; // 5 - const result = getShouldTimerUpdate(previousValue, currentValue); - expect(result).toBe(true); - }); -}); diff --git a/apps/server/src/services/runtime-service/rundownService.utils.ts b/apps/server/src/services/runtime-service/rundownService.utils.ts index 56d28e942..a23bc1db0 100644 --- a/apps/server/src/services/runtime-service/rundownService.utils.ts +++ b/apps/server/src/services/runtime-service/rundownService.utils.ts @@ -1,7 +1,27 @@ import { millisToSeconds } from 'ontime-utils'; -import { EntryId, isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeEvent, Rundown, TimerType } from 'ontime-types'; +import { + EntryId, + GroupState, + isOntimeEvent, + isPlayableEvent, + MaybeNumber, + OntimeEvent, + Rundown, + Runtime, + TimerState, + TimerType, + UpcomingEntry, +} from 'ontime-types'; -import { timerConfig } from '../../setup/config.js'; +import { deepEqual } from 'fast-equals'; + +export function isNewSecond( + previousValue: MaybeNumber | undefined, + currentValue: MaybeNumber | undefined, + direction: TimerType.CountDown | TimerType.CountUp = TimerType.CountDown, +) { + return millisToSeconds(currentValue ?? null, direction) !== millisToSeconds(previousValue ?? null, direction); +} /** * Checks whether we should update the clock value @@ -17,21 +37,69 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole * Checks whether we should update the timer value * - we have rolled into a new seconds unit */ -export function getShouldTimerUpdate(previousValue: MaybeNumber, currentValue: MaybeNumber): boolean { - const shouldUpdateTimer = millisToSeconds(currentValue) !== millisToSeconds(previousValue); - return shouldUpdateTimer; +export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean { + if (previousValue === undefined) return true; + return ( + // current timer value + isNewSecond(previousValue.current, currentValue.current) || + //secondary timer value, when in pre-roll + isNewSecond(previousValue.secondaryTimer, currentValue.secondaryTimer) || + // other timer values that could have changed + previousValue.addedTime !== currentValue.addedTime || + previousValue.duration !== currentValue.duration || + previousValue.phase !== currentValue.phase || + previousValue.playback !== currentValue.playback || + previousValue.startedAt !== currentValue.startedAt + // elapsed - this would be the direct invert of current value so no need to check + // expectedFinish - this will be moved out by the current value going into over time, no need to check + ); } -/** - * In some cases we want to force an update to the timer - * - if the clock has slid back - * - if we have escaped the update rate (clock slid forward) - * - if we are not playing then there is no need to update the timer - */ -export function getForceUpdate(previousUpdate: number, now: number): boolean { - const isClockBehind = now < previousUpdate; - const hasExceededRate = now - previousUpdate >= timerConfig.notificationRate; - return isClockBehind || hasExceededRate; +export function getShouldRuntimeUpdate( + previousValue: Runtime | undefined, + currentValue: Runtime, + didDependencyUpdate: boolean, +): boolean { + if (previousValue === undefined) return true; + if (didDependencyUpdate) return !deepEqual(previousValue, currentValue); + + return ( + previousValue.selectedEventIndex !== currentValue.selectedEventIndex || + previousValue.numEvents !== currentValue.numEvents || + previousValue.plannedStart !== currentValue.plannedStart || + previousValue.plannedEnd !== currentValue.plannedEnd || + previousValue.actualStart !== currentValue.actualStart || + previousValue.offsetMode !== currentValue.offsetMode + // offsetAbs, offsetRel and expectedEnd are ticked with `didDependencyUpdate` + ); +} + +export function getShouldGroupUpdate( + previousValue: GroupState | null | undefined, + currentValue: GroupState | null, + didDependencyUpdate: boolean, +): boolean { + if (previousValue === undefined) return true; + if (didDependencyUpdate) return !deepEqual(previousValue, currentValue); + + return ( + previousValue?.id !== currentValue?.id || previousValue?.startedAt !== currentValue?.startedAt + // expectedEnd are ticked with `didDependencyUpdate` + ); +} + +export function getShouldFlagUpdate( + previousValue: UpcomingEntry | null | undefined, + currentValue: UpcomingEntry | null, + didDependencyUpdate: boolean, +): boolean { + if (previousValue === undefined) return true; + if (didDependencyUpdate) return !deepEqual(previousValue, currentValue); + + return ( + previousValue?.id !== currentValue?.id + // expectedStart + ); } /** diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 3b7c67937..3b34fc89e 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,6 +1,6 @@ import { - CurrentGroupState, - EntryMetaData, + GroupState, + UpcomingEntry, isOntimeEvent, MaybeNumber, MaybeString, @@ -36,9 +36,9 @@ type ExpectedMetadata = { event: OntimeEvent; accumulatedGap: number; isLinkedTo export type RuntimeState = { clock: number; // realtime clock - groupNow: CurrentGroupState | null; + groupNow: GroupState | null; groupNext: MaybeString; - nextFlag: EntryMetaData | null; + nextFlag: UpcomingEntry | null; eventNow: PlayableEvent | null; eventNext: PlayableEvent | null; runtime: Runtime; @@ -802,7 +802,7 @@ export function loadGroupFlagAndEnd( // and the loaded event is not allowed to be the next flag if (!foundFlag && metadata.flags.includes(entry.id)) { foundFlag = true; - state.nextFlag = { id: entry.id, actualStart: null, expectedStart: null, expectedEnd: null }; + state.nextFlag = { id: entry.id, expectedStart: null }; state._flag = { event: entry, isLinkedToLoaded, accumulatedGap }; } } diff --git a/packages/types/src/definitions/runtime/CurrentGroupState.type.ts b/packages/types/src/definitions/runtime/CurrentGroupState.type.ts index 9db2e943e..9405ec088 100644 --- a/packages/types/src/definitions/runtime/CurrentGroupState.type.ts +++ b/packages/types/src/definitions/runtime/CurrentGroupState.type.ts @@ -1,7 +1,7 @@ import type { MaybeNumber } from '../../utils/utils.type.js'; import type { EntryId } from '../core/OntimeEntry.js'; -export type CurrentGroupState = { +export type GroupState = { id: EntryId; startedAt: MaybeNumber; expectedEnd: MaybeNumber; @@ -9,12 +9,5 @@ export type CurrentGroupState = { export type UpcomingEntry = { id: EntryId; - start: number; -}; - -export type EntryMetaData = { - id: EntryId; - actualStart: MaybeNumber; expectedStart: MaybeNumber; - expectedEnd: MaybeNumber; }; diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts index 00ba4cd48..f863d1d5a 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -1,7 +1,7 @@ import type { MaybeString } from '../../utils/utils.type.js'; import type { OntimeEvent } from '../core/OntimeEntry.js'; import type { SimpleTimerState } from './AuxTimer.type.js'; -import type { CurrentGroupState, EntryMetaData } from './CurrentGroupState.type.js'; +import type { GroupState, UpcomingEntry } from './CurrentGroupState.type.js'; import type { MessageState } from './MessageControl.type.js'; import type { Runtime } from './Runtime.type.js'; import type { TimerState } from './TimerState.type.js'; @@ -19,9 +19,9 @@ export type RuntimeStore = { eventNow: OntimeEvent | null; eventNext: OntimeEvent | null; - groupNow: CurrentGroupState | null; + groupNow: GroupState | null; groupNext: MaybeString; - nextFlag: EntryMetaData | null; + nextFlag: UpcomingEntry | null; // extra timers auxtimer1: SimpleTimerState; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a9c149ddf..3023cc1a2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -101,7 +101,7 @@ export { OffsetMode } from './definitions/runtime/Runtime.type.js'; export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js'; export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js'; -export type { CurrentGroupState, UpcomingEntry, EntryMetaData } from './definitions/runtime/CurrentGroupState.type.js'; +export type { GroupState, UpcomingEntry } from './definitions/runtime/CurrentGroupState.type.js'; // ---> Extra Timer export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';