refactor: runtime service (#1722)

* refactor: trim type

* refactor: update runtime service data push logic

* keep using just deep

* reword didDependencyUpdate

* remove dulicate
This commit is contained in:
Alex Christoffer Rasmussen
2025-08-10 18:45:47 +02:00
committed by Carlos Valente
parent e0149c1cbf
commit 022a58e142
7 changed files with 145 additions and 190 deletions
@@ -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,
@@ -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);
});
});
@@ -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
);
}
/**
+5 -5
View File
@@ -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 };
}
}
@@ -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;
};
@@ -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;
+1 -1
View File
@@ -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';