From faca1a1c76d7d29812f10d0118e9b86cb42164f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 21:25:03 +0000 Subject: [PATCH] refactor(report): reduce footprint and take writes off the cue path Cuts the risk surface of the run history feature while keeping what it delivers. Stability: - Sidecar writes were happening on every event stop, and each write serialised the project's entire history. That put work proportional to everything that ever ran onto the show critical path, growing without bound. Writes during a run are now coalesced, with immediate writes for anything a user did and a flush on finish, project change and shutdown. - The editor no longer carries any of this feature's code. The per event last run chip is gone, so RundownEventChip, RundownEventInner and useProjectRundowns are back to their previous state. Each rundown row had gained two extra query subscriptions, one of them polling. - closeRun is no longer called from runtime.service, so the timer path is untouched. Ending a run is now explicit, which also means a mid show stop and restart no longer splits one show across two runs. Discovery: - A run indicator in the editor overview appears only while a run is being recorded. It shows when recording started and carries the Finish action, then links to the report. One component with one subscription, rather than anything per row. Smaller: - report.parser drops from exhaustive validation of a file we write ourselves to a shallow shape check; the failure mode is unchanged. - getCombinedReport returns to its original shape, keeping only the snapshot read that report accuracy depends on. - Removes getLatestRun and GET /runs/latest, which only existed for the chip, and hand rolled refetches the api layer already covers. Production diff is down from ~1460 to ~1290 lines, and the four floating promises the previous revision introduced are gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV --- apps/client/src/common/api/constants.ts | 1 - apps/client/src/common/api/report.ts | 19 +- .../common/hooks-query/useProjectRundowns.ts | 10 -- apps/client/src/common/hooks-query/useRuns.ts | 21 +-- .../panel/feature-panel/ReportSettings.tsx | 2 +- .../__tests__/reportSettings.utils.test.ts | 94 ++++------ .../feature-panel/composite/RunsList.tsx | 13 +- .../feature-panel/reportSettings.utils.ts | 169 +++++++----------- .../src/features/overview/EditorOverview.tsx | 3 + .../composite/RunIndicator.module.scss | 27 +++ .../overview/composite/RunIndicator.tsx | 58 ++++++ .../rundown-event/RundownEventInner.tsx | 1 + .../composite/RundownEventChip.module.scss | 6 - .../composite/RundownEventChip.tsx | 78 ++++---- .../report/__tests__/report.service.test.ts | 88 ++++++--- .../src/api-data/report/report.router.ts | 9 +- .../src/api-data/report/report.service.ts | 49 ++--- apps/server/src/app.ts | 9 + .../__tests__/report.store.test.ts | 48 +++++ .../services/report-service/report.parser.ts | 124 ++----------- .../services/report-service/report.store.ts | 79 ++++++-- .../runtime-service/runtime.service.ts | 4 +- 22 files changed, 474 insertions(+), 438 deletions(-) create mode 100644 apps/client/src/features/overview/composite/RunIndicator.module.scss create mode 100644 apps/client/src/features/overview/composite/RunIndicator.tsx diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index 7cd8d3805..25723da4b 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -21,7 +21,6 @@ export const CSS_OVERRIDE = ['cssOverride']; export const CLIENT_LIST = ['clientList']; export const REPORT = ['report']; export const REPORT_RUNS = ['report', 'runs']; -export const getLatestRunQueryKey = (rundownId?: string) => ['report', 'runs', 'latest', rundownId ?? null]; export const TRANSLATION = ['translation']; // API URLs diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts index 22a651ce6..31b5c4c59 100644 --- a/apps/client/src/common/api/report.ts +++ b/apps/client/src/common/api/report.ts @@ -45,22 +45,11 @@ export async function fetchRun(id: string, options?: RequestOptions): Promise { - try { - const res = await axios.get(`${reportUrl}/runs/latest`, { - signal: options?.signal, - params: rundownId ? { rundownId } : undefined, - }); - return res.data; - } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 404) { - return null; - } - throw error; - } +export async function finishRun(): Promise { + await axios.post(`${reportUrl}/runs/finish`); + await ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); } export async function renameRun(id: string, label: string): Promise { diff --git a/apps/client/src/common/hooks-query/useProjectRundowns.ts b/apps/client/src/common/hooks-query/useProjectRundowns.ts index 32299a0ec..45d83f42f 100644 --- a/apps/client/src/common/hooks-query/useProjectRundowns.ts +++ b/apps/client/src/common/hooks-query/useProjectRundowns.ts @@ -26,16 +26,6 @@ export function useProjectRundowns() { return { data: data ?? { loaded: '', rundowns: [] }, status, isError, refetch, isFetching }; } -/** - * The id of the currently loaded rundown, or null if none is loaded yet. - * Reads from the same lightweight summary query as `useProjectRundowns`, so - * consumers that only need the id are not subscribed to full rundown entries. - */ -export function useLoadedRundownId(): string | null { - const { data } = useProjectRundowns(); - return data.loaded || null; -} - export function useMutateProjectRundowns() { const ontimeQueryClient = useQueryClient(); diff --git a/apps/client/src/common/hooks-query/useRuns.ts b/apps/client/src/common/hooks-query/useRuns.ts index fb5940f30..16228de0e 100644 --- a/apps/client/src/common/hooks-query/useRuns.ts +++ b/apps/client/src/common/hooks-query/useRuns.ts @@ -2,8 +2,8 @@ import { useQuery } from '@tanstack/react-query'; import { ShowRun, ShowRunSummary } from 'ontime-types'; import { MILLIS_PER_HOUR } from 'ontime-utils'; -import { getLatestRunQueryKey, REPORT_RUNS } from '../api/constants'; -import { fetchLatestRun, fetchRun, fetchRuns } from '../api/report'; +import { REPORT_RUNS } from '../api/constants'; +import { fetchRun, fetchRuns } from '../api/report'; /** * Run history for the current project, optionally scoped to a rundown @@ -33,17 +33,8 @@ export function useRun(id: string | null) { return { data, status }; } -/** - * Most recently closed run, used to compare a rundown against its last outing. - * Returns null while there is no run to compare against. - */ -export function useLatestRun(rundownId?: string) { - const { data, status } = useQuery({ - queryKey: getLatestRunQueryKey(rundownId), - queryFn: ({ signal }) => fetchLatestRun(rundownId, { signal }), - placeholderData: (previousData, _previousQuery) => previousData, - staleTime: MILLIS_PER_HOUR, - }); - - return { data: data ?? null, status }; +/** The run currently in progress, if any */ +export function useOpenRun(): ShowRunSummary | null { + const { data } = useRuns(); + return data.find((run) => run.endedAt === null) ?? null; } diff --git a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx index 46446a0b4..363a9457f 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/ReportSettings.tsx @@ -45,7 +45,7 @@ export default function ReportSettings() { A run is created the first time an event is started, and is added to history once playback stops. Every run keeps its own snapshot of the schedule, so editing the rundown later does not change past reports. - + {selectedRunId && ( diff --git a/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts b/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts index 994c959fc..0b6749d14 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts +++ b/apps/client/src/features/app-settings/panel/feature-panel/__tests__/reportSettings.utils.test.ts @@ -1,6 +1,6 @@ import { EndAction, OntimeEvent, OntimeReport, RundownEntries, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types'; -import { getCombinedReport, makeReportCSV } from '../reportSettings.utils'; +import { formatDrift, getCombinedReport, makeReportCSV } from '../reportSettings.utils'; function makeEvent(patch: Partial): OntimeEvent { return { @@ -39,24 +39,25 @@ describe('getCombinedReport()', () => { }); it('includes an event which has not run, using the current schedule', () => { - const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 }); - const rundownEntries: RundownEntries = { a: entry }; + const notRun = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 }); + const didRun = makeEvent({ id: 'b', timeStart: 10000, timeEnd: 20000 }); + const rundownEntries: RundownEntries = { a: notRun, b: didRun }; + const report: OntimeReport = { + b: { startedAt: 10000, endedAt: 20000, scheduledStart: 10000, scheduledDuration: 10000, playCount: 1 }, + }; - const result = getCombinedReport({}, rundownEntries, ['a']); + const result = getCombinedReport(report, rundownEntries, ['a', 'b']); - expect(result).toEqual([ - { - id: 'a', - index: 1, - title: 'event title', - cue: '1', - scheduledStart: 0, - scheduledEnd: 10000, - actualStart: null, - actualEnd: null, - playCount: 0, - }, - ]); + expect(result[0]).toEqual({ + id: 'a', + index: 1, + title: 'event title', + cue: '1', + scheduledStart: 0, + scheduledEnd: 10000, + actualStart: null, + actualEnd: null, + }); }); it('uses the snapshot taken when the event ran, not the current rundown', () => { @@ -73,50 +74,35 @@ describe('getCombinedReport()', () => { scheduledEnd: 10000, // from the snapshot, not the edited timeEnd of 99999 actualStart: 100, actualEnd: 10100, - playCount: 1, }); }); - it('keeps an event which ran but has since been removed from the rundown', () => { - const report: OntimeReport = { - deleted: { startedAt: 0, endedAt: 5000, scheduledStart: 0, scheduledDuration: 5000, playCount: 1 }, - }; - - const result = getCombinedReport(report, {}, []); - - expect(result).toEqual([ - { - id: 'deleted', - index: 1, - title: '(deleted event)', - cue: '–', - scheduledStart: 0, - scheduledEnd: 5000, - actualStart: 0, - actualEnd: 5000, - playCount: 1, - }, - ]); - }); - - it('orders rundown events first, deleted events after', () => { - const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 }); - const report: OntimeReport = { - a: { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 }, - deleted: { startedAt: 10000, endedAt: 15000, scheduledStart: 10000, scheduledDuration: 5000, playCount: 1 }, - }; - - const result = getCombinedReport(report, { a: entry }, ['a']); - - expect(result.map((entry) => entry.id)).toEqual(['a', 'deleted']); - }); - it('skips entries which are not events', () => { + const entry = makeEvent({ id: 'a', timeStart: 0, timeEnd: 10000 }); const rundownEntries: RundownEntries = { + a: entry, delay: { type: SupportedEntry.Delay, id: 'delay', duration: 1000, parent: null }, }; + const report: OntimeReport = { + a: { startedAt: 0, endedAt: 10000, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 }, + }; - expect(getCombinedReport({}, rundownEntries, ['delay'])).toEqual([]); + expect(getCombinedReport(report, rundownEntries, ['delay', 'a']).map((row) => row.id)).toEqual(['a']); + }); +}); + +describe('formatDrift()', () => { + it('has nothing to report when no event completed', () => { + expect(formatDrift(0, 0)).toBe('–'); + }); + + it('treats sub-second drift as on time', () => { + expect(formatDrift(500, 3)).toBe('On time'); + }); + + it('signs the drift in both directions', () => { + expect(formatDrift(252000, 3)).toBe('+4m12s'); + expect(formatDrift(-60000, 3)).toBe('-1m'); }); }); @@ -132,12 +118,10 @@ describe('makeReportCSV()', () => { scheduledEnd: 10000, actualStart: 0, actualEnd: 12000, - playCount: 1, }, ]); const rows = csv.trim().split('\n'); expect(rows).toHaveLength(2); - expect(rows[0]).toContain('Play count'); }); }); diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/RunsList.tsx b/apps/client/src/features/app-settings/panel/feature-panel/composite/RunsList.tsx index 34d41a401..7c7866066 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/composite/RunsList.tsx +++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/RunsList.tsx @@ -7,7 +7,6 @@ import { maybeAxiosError } from '../../../../../common/api/utils'; import IconButton from '../../../../../common/components/buttons/IconButton'; import Input from '../../../../../common/components/input/input/Input'; import Tag from '../../../../../common/components/tag/Tag'; -import useRuns from '../../../../../common/hooks-query/useRuns'; import { preventEscape } from '../../../../../common/utils/keyEvent'; import { cx } from '../../../../../common/utils/styleUtils'; import * as Panel from '../../../panel-utils/PanelUtils'; @@ -17,13 +16,11 @@ import style from './RunsList.module.scss'; interface RunsListProps { runs: ShowRunSummary[]; - rundownId?: string; selectedRunId: string | null; onSelect: (id: string) => void; } -export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: RunsListProps) { - const { refetch } = useRuns(rundownId); +export default function RunsList({ runs, selectedRunId, onSelect }: RunsListProps) { const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [error, setError] = useState(null); @@ -33,6 +30,8 @@ export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: R setRenameValue(run.label); }; + // the api layer invalidates the report query key, which the run list + // hangs off, so these do not need to refetch by hand const submitRename = async (id: string) => { const label = renameValue.trim(); setRenamingId(null); @@ -44,8 +43,6 @@ export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: R await renameRun(id, label); } catch (renameError) { setError(maybeAxiosError(renameError)); - } finally { - refetch(); } }; @@ -59,8 +56,6 @@ export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: R } } catch (deleteError) { setError(maybeAxiosError(deleteError)); - } finally { - refetch(); } }; @@ -68,7 +63,7 @@ export default function RunsList({ runs, rundownId, selectedRunId, onSelect }: R preventEscape(event, () => setRenamingId(null)); if (event.key === 'Enter') { event.preventDefault(); - submitRename(id); + void submitRename(id); } }; diff --git a/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts b/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts index eba870aec..301469302 100644 --- a/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts +++ b/apps/client/src/features/app-settings/panel/feature-panel/reportSettings.utils.ts @@ -1,9 +1,75 @@ -import { EntryId, MaybeNumber, OntimeEventReport, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types'; +import { EntryId, MaybeNumber, OntimeReport, RundownEntries, isOntimeEvent } from 'ontime-types'; import { MILLIS_PER_SECOND } from 'ontime-utils'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; import { formatDuration, formatTime } from '../../../../common/utils/time'; +export type CombinedReport = { + id: EntryId; + index: number; + title: string; + cue: string; + scheduledStart: number; + actualStart: MaybeNumber; + scheduledEnd: number; + actualEnd: MaybeNumber; +}; + +/** + * Creates a combined report with the rundown data. + * + * Events that ran are measured against the schedule recorded at the time, + * not the rundown's current values, so editing the rundown afterwards does + * not rewrite a past run. Events that never ran have no snapshot and fall + * back to the rundown. + */ +export function getCombinedReport( + report: OntimeReport, + rundown: RundownEntries, + flatOrder: EntryId[], +): CombinedReport[] { + if (Object.keys(report).length === 0) return []; + if (flatOrder.length === 0) return []; + + const combinedReport: CombinedReport[] = []; + + let index = 1; + for (let i = 0; i < flatOrder.length; i++) { + const id = flatOrder[i]; + const entry = rundown[id]; + if (!entry || !isOntimeEvent(entry)) continue; + + const reported = report[id]; + + if (!reported) { + combinedReport.push({ + id: id, + index: index, + title: entry.title, + cue: entry.cue, + scheduledStart: entry.timeStart, + actualEnd: null, + scheduledEnd: entry.timeEnd, + actualStart: null, + }); + } else { + combinedReport.push({ + id: id, + index: index, + title: entry.title, + cue: entry.cue, + scheduledStart: reported.scheduledStart, + actualEnd: reported.endedAt, + scheduledEnd: reported.scheduledStart + reported.scheduledDuration, + actualStart: reported.startedAt, + }); + } + index++; + } + + return combinedReport; +} + /** * Signed drift for a run, eg "+4m 12s" / "-1m". A run with no completed * events has no meaningful drift to report. @@ -14,105 +80,7 @@ export function formatDrift(drift: number, eventsRun: number): string { return `${drift > 0 ? '+' : '-'}${formatDuration(Math.abs(drift), false)}`; } -export type CombinedReport = { - id: EntryId; - index: number; - title: string; - cue: string; - scheduledStart: number; - actualStart: MaybeNumber; - scheduledEnd: number; - actualEnd: MaybeNumber; - playCount: number; -}; - -/** - * Creates a combined report joining a run's per event data with the rundown. - * - * A run's report keeps its own snapshot of the schedule (`scheduledStart` / - * `scheduledDuration`), so an event that ran is shown against the schedule as - * it was at the time, not against whatever the rundown has been edited to since. - * - * Events that ran but no longer exist in the rundown (deleted since the run) - * are still included, from the snapshot alone, so a historical run stays a - * complete record even after the rundown changes. - */ -export function getCombinedReport( - report: OntimeReport, - rundownEntries: RundownEntries, - flatOrder: EntryId[], -): CombinedReport[] { - if (Object.keys(report).length === 0 && flatOrder.length === 0) return []; - - const combinedReport: CombinedReport[] = []; - const seen = new Set(); - let index = 1; - - for (const id of flatOrder) { - const entry = rundownEntries[id]; - if (!entry || !isOntimeEvent(entry)) continue; - - seen.add(id); - combinedReport.push(makeCombinedEntry(id, index, entry.title, entry.cue, report[id], entry.timeStart, entry.timeEnd)); - index++; - } - - for (const [id, reportEntry] of Object.entries(report)) { - if (seen.has(id)) continue; - combinedReport.push( - makeCombinedEntry( - id, - index, - '(deleted event)', - '–', - reportEntry, - reportEntry.scheduledStart, - reportEntry.scheduledStart + reportEntry.scheduledDuration, - ), - ); - index++; - } - - return combinedReport; -} - -function makeCombinedEntry( - id: EntryId, - index: number, - title: string, - cue: string, - reportEntry: OntimeEventReport | undefined, - fallbackStart: number, - fallbackEnd: number, -): CombinedReport { - if (!reportEntry) { - return { - id, - index, - title, - cue, - scheduledStart: fallbackStart, - scheduledEnd: fallbackEnd, - actualStart: null, - actualEnd: null, - playCount: 0, - }; - } - - return { - id, - index, - title, - cue, - scheduledStart: reportEntry.scheduledStart, - scheduledEnd: reportEntry.scheduledStart + reportEntry.scheduledDuration, - actualStart: reportEntry.startedAt, - actualEnd: reportEntry.endedAt, - playCount: reportEntry.playCount, - }; -} - -const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End', 'Play count']; +const csvHeader = ['Index', 'Title', 'Cue', 'Scheduled Start', 'Actual Start', 'Scheduled End', 'Actual End']; /** * Transforms a CombinedReport into a CSV string @@ -130,7 +98,6 @@ export function makeReportCSV(combinedReport: CombinedReport[]) { formatTime(entry.actualStart), formatTime(entry.scheduledEnd), formatTime(entry.actualEnd), - String(entry.playCount), ]); } diff --git a/apps/client/src/features/overview/EditorOverview.tsx b/apps/client/src/features/overview/EditorOverview.tsx index ef05a8455..9f23b66c9 100644 --- a/apps/client/src/features/overview/EditorOverview.tsx +++ b/apps/client/src/features/overview/EditorOverview.tsx @@ -10,6 +10,7 @@ import { StartTimesPlanning, StartTimesRuntime, } from './composite/TimeElements'; +import RunIndicator from './composite/RunIndicator'; import TitleOverview from './composite/TitleOverview'; import { OverviewWrapper } from './OverviewWrapper'; @@ -25,6 +26,8 @@ function EditorOverview({ children }: PropsWithChildren) { {layoutMode === EditorLayoutMode.PLANNING && } {layoutMode === EditorLayoutMode.TRACKING && } {layoutMode === EditorLayoutMode.CONTROL && } + {/* renders nothing unless a run is being recorded */} + ); } diff --git a/apps/client/src/features/overview/composite/RunIndicator.module.scss b/apps/client/src/features/overview/composite/RunIndicator.module.scss new file mode 100644 index 000000000..f8ea8bcbe --- /dev/null +++ b/apps/client/src/features/overview/composite/RunIndicator.module.scss @@ -0,0 +1,27 @@ +.indicator { + display: flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; +} + +.label { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: calc(1rem - 3px); + color: $label-gray; +} + +.dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background-color: $active-red; + flex-shrink: 0; +} + +.since { + color: $ui-white; + font-weight: 600; +} diff --git a/apps/client/src/features/overview/composite/RunIndicator.tsx b/apps/client/src/features/overview/composite/RunIndicator.tsx new file mode 100644 index 000000000..dfe22128d --- /dev/null +++ b/apps/client/src/features/overview/composite/RunIndicator.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { finishRun } from '../../../common/api/report'; +import Button from '../../../common/components/buttons/Button'; +import Tooltip from '../../../common/components/tooltip/Tooltip'; +import { useOpenRun } from '../../../common/hooks-query/useRuns'; + +import style from './RunIndicator.module.scss'; + +/** + * Shows that a show report is being recorded, and lets the operator close it. + * + * This is the feature's home in the editor: it appears only while a run is + * open, so it stays out of the way until it is relevant, and finishing here + * is what writes the run to history. + */ +export default function RunIndicator() { + const openRun = useOpenRun(); + const [isFinishing, setIsFinishing] = useState(false); + const navigate = useNavigate(); + + if (!openRun) { + return null; + } + + const handleFinish = async () => { + setIsFinishing(true); + try { + await finishRun(); + // take the user to the report they just made, which is also how most + // people will discover that the history exists + void navigate('/editor?settings=sharing__report'); + } catch (_error) { + /** the run stays open, the user can try again */ + } finally { + setIsFinishing(false); + } + }; + + return ( +
+ }> + + + {/* startedAt is a wall clock instant, not a time of day, so it is not formatTime's job */} + Recording since{' '} + + {new Date(openRun.startedAt).toLocaleTimeString(undefined, { timeStyle: 'short' })} + + + + +
+ ); +} diff --git a/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx b/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx index 192431633..a0b53e715 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEventInner.tsx @@ -141,6 +141,7 @@ function RundownEventInner({ isPast={isPast} isLoaded={loaded} totalGap={totalGap} + duration={duration} /> )}
diff --git a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.module.scss b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.module.scss index e918bdbf0..a98c07977 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.module.scss +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.module.scss @@ -16,10 +16,4 @@ &.due { color: $warning-orange; } - - // preview of how this event went last time it ran, shown before it plays again - &.muted { - color: $label-gray; - opacity: 0.7; - } } diff --git a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx index d489aa887..55cde6fcd 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventChip.tsx @@ -1,19 +1,10 @@ import { Day } from 'ontime-types'; -import { - EventVariance, - MILLIS_PER_MINUTE, - MILLIS_PER_SECOND, - getEventVariance, - isPlaybackActive, - millisToString, -} from 'ontime-utils'; +import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, isPlaybackActive, millisToString } from 'ontime-utils'; import { useMemo } from 'react'; import { IoCheckmarkCircle } from 'react-icons/io5'; import Tooltip from '../../../../common/components/tooltip/Tooltip'; -import { useLoadedRundownId } from '../../../../common/hooks-query/useProjectRundowns'; import useReport from '../../../../common/hooks-query/useReport'; -import { useLatestRun } from '../../../../common/hooks-query/useRuns'; import { usePlayback } from '../../../../common/hooks/useSocket'; import { cx } from '../../../../common/utils/styleUtils'; import { formatDuration, useTimeUntilExpectedStart } from '../../../../common/utils/time'; @@ -29,6 +20,7 @@ interface RundownEventChipProps { isLoaded: boolean; className: string; totalGap: number; + duration: number; isLinkedToLoaded: boolean; } @@ -41,6 +33,7 @@ export default function RundownEventChip({ className, totalGap, id, + duration, isLinkedToLoaded, }: RundownEventChipProps) { const playback = usePlayback(); @@ -52,7 +45,7 @@ export default function RundownEventChip({ const playbackActive = isPlaybackActive(playback); if (!playbackActive || isPast) { - return ; + return ; } if (playbackActive) { @@ -93,60 +86,49 @@ function EventUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }: interface EventReportProps { className: string; id: string; + duration: number; } function EventReport(props: EventReportProps) { - const { className, id } = props; + const { className, id, duration } = props; const { data } = useReport(); const currentReport = data[id]; - // an event with nothing in the current run's report yet can still show how - // it went last time, so a repeat show previews its schedule before it plays - const loadedRundownId = useLoadedRundownId(); - const { data: lastRun } = useLatestRun(loadedRundownId ?? undefined); - - const [value, chipStyle, tooltip] = useMemo(() => { - // compares against the schedule as it was when the event ran, not the - // rundown's current values, so an edit made afterwards cannot change this - const variance = getEventVariance(currentReport); - if (variance.status !== 'not-run') { - return describeVariance(variance, false); + const [value, overUnderStyle, tooltip] = useMemo(() => { + if (!currentReport) { + return [null, 'none', '']; } - const lastRunVariance = getEventVariance(lastRun?.report[id]); - if (lastRunVariance.status !== 'not-run') { - return describeVariance(lastRunVariance, true); + const { startedAt, endedAt } = currentReport; + if (!startedAt || !endedAt) { + return [null, 'none', '']; } - return [null, 'none', '']; - }, [currentReport, id, lastRun]); + const actualDuration = endedAt - startedAt; + const difference = actualDuration - duration; + const absDifference = Math.abs(difference); + + if (absDifference < MILLIS_PER_SECOND) { + return ['ontime', 'under', 'Event finished on time']; + } + + const isOver = difference > 0; + + const fullTimeValue = millisToString(absDifference); + + const tooltip = `Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`; + + const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`; + return [value, isOver ? 'over' : 'under', tooltip]; + }, [currentReport, duration]); if (!value) { return null; } return ( - } className={cx([style.chip, style[chipStyle], className])}> + } className={cx([style.chip, style[overUnderStyle], className])}> {value === 'ontime' ? : value} ); } - -/** - * Formats a variance into the chip's value, style and tooltip. - * `muted` marks a preview of a past run rather than a live status. - */ -function describeVariance(variance: EventVariance, muted: boolean): [string, string, string] { - const prefix = muted ? 'Last run: ' : ''; - - if (variance.status === 'ontime') { - return ['ontime', muted ? 'muted' : 'under', `${prefix}Event finished on time`]; - } - - const absDifference = Math.abs(variance.delta); - const isOver = variance.status === 'over'; - const fullTimeValue = millisToString(absDifference); - const tooltip = `${prefix}Event ran ${isOver ? 'over' : 'under'} time by ${fullTimeValue}`; - const value = `${isOver ? '+' : '-'}${formatDuration(absDifference, absDifference > 2 * MILLIS_PER_MINUTE)}`; - return [value, muted ? 'muted' : isOver ? 'over' : 'under', tooltip]; -} diff --git a/apps/server/src/api-data/report/__tests__/report.service.test.ts b/apps/server/src/api-data/report/__tests__/report.service.test.ts index 415ee90ee..e5b91501b 100644 --- a/apps/server/src/api-data/report/__tests__/report.service.test.ts +++ b/apps/server/src/api-data/report/__tests__/report.service.test.ts @@ -7,6 +7,8 @@ import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.moc // in-memory stand-in for the sidecar store, verified separately in report.store.test.ts let runs: ShowRun[] = []; +/** write options seen by the store, so we can assert what reaches the disk and when */ +let writeOptions: ({ debounce?: boolean } | undefined)[] = []; vi.mock('../../../services/report-service/report.store.js', () => ({ // isolation between tests comes from the top-level beforeEach resetting `runs`, @@ -14,7 +16,8 @@ vi.mock('../../../services/report-service/report.store.js', () => ({ loadReports: vi.fn(async () => ({ runs })), getRuns: vi.fn(() => runs), getRun: vi.fn((id: string) => runs.find((run) => run.id === id)), - upsertRun: vi.fn(async (run: ShowRun) => { + upsertRun: vi.fn(async (run: ShowRun, options?: { debounce?: boolean }) => { + writeOptions.push(options); const index = runs.findIndex((candidate) => candidate.id === run.id); if (index === -1) { runs.unshift(run); @@ -74,7 +77,6 @@ const { initReports, listRuns, getRun, - getLatestRun, renameRun, deleteRun, deleteAllRuns, @@ -85,6 +87,7 @@ const eventB = makeOntimeEvent({ id: 'event-b', timeStart: 10000, timeEnd: 20000 beforeEach(async () => { runs = []; + writeOptions = []; currentRundown = makeRundown({ id: 'rundown-1', title: 'Test rundown', @@ -166,6 +169,48 @@ describe('triggerReportEntry()', () => { }); }); +describe('write pressure on the show critical path', () => { + function runEvent(clock: number) { + const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock, _startEpoch: showEpoch }); + triggerReportEntry(TimerLifeCycle.onStart, state); + triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: clock + 1000 } as typeof state); + } + + it('coalesces writes while a run is in progress', async () => { + // the sidecar holds the whole project history, so writing per event would + // put work proportional to everything that ever ran onto the cue path + runEvent(0); + runEvent(2000); + runEvent(4000); + await new Promise((resolve) => setImmediate(resolve)); + + expect(writeOptions.length).toBeGreaterThan(0); + expect(writeOptions.every((options) => options?.debounce === true)).toBe(true); + }); + + it('writes immediately when the run is finished', async () => { + runEvent(0); + await new Promise((resolve) => setImmediate(resolve)); + writeOptions = []; + + await closeRun(); + + expect(writeOptions).toHaveLength(1); + expect(writeOptions[0]?.debounce).toBeFalsy(); + }); + + it('writes immediately when a user renames a run', async () => { + runEvent(0); + await closeRun(); + const runId = listRuns()[0].id; + writeOptions = []; + + await renameRun(runId, 'Dress rehearsal'); + + expect(writeOptions[0]?.debounce).toBeFalsy(); + }); +}); + describe('run timestamps', () => { it('dates a run with the wall clock epoch, not the time of day', async () => { // clock/actualStart are millis since midnight, which cannot date a run. @@ -198,19 +243,19 @@ describe('run timestamps', () => { expect(run.startedAt).toBeLessThanOrEqual(Date.now()); }); - it('orders runs from different days correctly', async () => { - // a run at 09:30 today must rank above one at 20:00 yesterday, which - // time-of-day ordering would get backwards + it('dates runs from different days apart', async () => { + // the times of day here disagree with chronological order: 20:00 yesterday + // is a larger time of day than 09:30 today const yesterdayEvening = Date.UTC(2026, 7, 7, 20, 0); - await makeClosedRunAt('older', yesterdayEvening); - await makeClosedRunAt('newer', showEpoch); + await makeClosedRunAt(yesterdayEvening); + const older = listRuns()[0].startedAt; + await makeClosedRunAt(showEpoch); + const newer = listRuns()[0].startedAt; - expect(getLatestRun()?.id).toBe('newer'); + expect(newer).toBeGreaterThan(older); }); - async function makeClosedRunAt(id: string, epoch: number) { - // the time of day deliberately disagrees with chronological order here: - // 20:00 yesterday is a larger time of day than 09:30 today + async function makeClosedRunAt(epoch: number) { const timeOfDay = epoch % 86400000; const state = makeRuntimeStateData({ eventNow: eventA, @@ -221,9 +266,7 @@ describe('run timestamps', () => { }); triggerReportEntry(TimerLifeCycle.onStart, state); triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: timeOfDay + 10000 } as typeof state); - closeRun(); - await new Promise((resolve) => setImmediate(resolve)); - runs[0] = { ...runs[0], id }; + await closeRun(); } }); @@ -233,7 +276,7 @@ describe('closeRun()', () => { triggerReportEntry(TimerLifeCycle.onStart, start); triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start); - closeRun(); + await closeRun(); await new Promise((resolve) => setImmediate(resolve)); expect(listRuns()).toHaveLength(1); @@ -248,8 +291,8 @@ describe('closeRun()', () => { expect(listRuns()).toHaveLength(2); }); - it('does nothing when no run is open', () => { - expect(() => closeRun()).not.toThrow(); + it('does nothing when no run is open', async () => { + await expect(closeRun()).resolves.toBeNull(); expect(listRuns()).toHaveLength(0); }); }); @@ -379,7 +422,7 @@ describe('run history queries and edits', () => { currentRundown = { ...currentRundown, id: rundownId }; triggerReportEntry(TimerLifeCycle.onStart, start); triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start); - closeRun(); + await closeRun(); await new Promise((resolve) => setImmediate(resolve)); // stamp a predictable id so tests can address the run directly const created = listRuns()[0]; @@ -396,15 +439,6 @@ describe('run history queries and edits', () => { expect(listRuns()).toHaveLength(2); }); - it('returns the most recently started closed run', async () => { - await makeClosedRun('older'); - await makeClosedRun('newer'); - runs.find((run) => run.id === 'older')!.startedAt = 0; - runs.find((run) => run.id === 'newer')!.startedAt = 100000; - - expect(getLatestRun()?.id).toBe('newer'); - }); - it('renames a run', async () => { await makeClosedRun('run-a'); const renamed = await renameRun('run-a', 'Dress rehearsal'); diff --git a/apps/server/src/api-data/report/report.router.ts b/apps/server/src/api-data/report/report.router.ts index 8758b83cd..bdc53aa78 100644 --- a/apps/server/src/api-data/report/report.router.ts +++ b/apps/server/src/api-data/report/report.router.ts @@ -24,12 +24,11 @@ router.get('/runs', validateRundownIdQuery, (req: Request, res: Response) => { }); /** - * Most recently closed run, used to compare a rundown against its last outing. - * Registered ahead of /runs/:id so "latest" is not read as an id. + * Closes the run in progress and writes it to history immediately. + * Registered ahead of /runs/:id so "finish" is not read as an id. */ -router.get('/runs/latest', validateRundownIdQuery, (req: Request, res: Response) => { - const { rundownId } = req.query as { rundownId?: string }; - const run = report.getLatestRun(rundownId); +router.post('/runs/finish', async (_req: Request, res: Response) => { + const run = await report.closeRun(); if (!run) { res.status(404).send(); return; diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts index 8e6132032..b374987c4 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -108,22 +108,26 @@ export function triggerReportEntry( } /** - * Closes the run in progress. - * Called when playback stops and events are unloaded, which is the operator - * saying the show is over. Pausing or loading another event does not end a run. + * Closes the run in progress and writes it to history immediately. + * + * Ending a run is an explicit act by the operator, not a side effect of + * playback. Stopping and starting again mid show is ordinary, and would + * otherwise split one show across several runs. + * @returns the closed run, or null if none was open */ -export function closeRun() { +export async function closeRun(): Promise { if (openRun === null) { - return; + return null; } - // detach the run before the async write so a start arriving in between - // opens a new run instead of appending to the one we are closing + // detach the run before the write so a start arriving in between opens a + // new run instead of appending to the one we are closing const closing = { ...openRun, endedAt: lastEndedAtIn(generate()) }; openRun = null; - void persistRun(closing, generate()); + const closed = await persistRun(closing, generate()); sendRefetch(RefetchKey.Report); + return closed; } /** @@ -154,6 +158,10 @@ function openRunIfNeeded(state: DeepReadonly) { startedAt, endedAt: null, }; + + // let the editor show that a run is being recorded without waiting + // for the first event to finish + sendRefetch(RefetchKey.Report); } /** @@ -177,14 +185,19 @@ async function persistOpenRun(): Promise { if (openRun === null) { return; } - await persistRun(openRun, generate()); + // debounced: this runs as events stop, which is the show critical path + await persistRun(openRun, generate(), { debounce: true }); } /** * Writes a run and its derived summary to the sidecar * @private */ -async function persistRun(run: Omit, currentReport: OntimeReport): Promise { +async function persistRun( + run: Omit, + currentReport: OntimeReport, + options?: reportStore.WriteOptions, +): Promise { const rundown = getRundownForRun(run.rundownId); // a rundown deleted mid-run leaves nothing to count against, so we keep // whatever the run was last written with rather than reporting zero @@ -192,11 +205,14 @@ async function persistRun(run: Omit, currentRepor ? countPlannedEvents(rundown.entries, rundown.flatOrder) : (reportStore.getRun(run.id)?.summary.eventsPlanned ?? 0); - await reportStore.upsertRun({ + const persisted: ShowRun = { ...run, report: structuredClone(currentReport), summary: getRunSummary(currentReport, eventsPlanned), - }); + }; + + await reportStore.upsertRun(persisted, options); + return persisted; } /** @@ -266,15 +282,6 @@ export function getRun(id: string): ShowRun | undefined { return reportStore.getRun(id); } -/** Most recent closed run, used to compare a rundown against its last outing */ -export function getLatestRun(rundownId?: string): ShowRun | undefined { - return reportStore - .getRuns() - .filter((run) => run.endedAt !== null && (rundownId === undefined || run.rundownId === rundownId)) - .sort((a, b) => b.startedAt - a.startedAt) - .at(0); -} - export async function renameRun(id: string, label: string): Promise { const run = reportStore.getRun(id); if (!run) { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 29d9d17a6..12c7e8dbd 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -28,6 +28,7 @@ import { ONTIME_VERSION } from './ONTIME_VERSION.js'; import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js'; import * as messageService from './services/message-service/message.service.js'; import { initialiseProject } from './services/project-service/ProjectService.js'; +import { flush as flushReports } from './services/report-service/report.store.js'; import { restoreService } from './services/restore-service/restore.service.js'; import type { RestorePoint } from './services/restore-service/restore.type.js'; import { runtimeService } from './services/runtime-service/runtime.service.js'; @@ -331,6 +332,14 @@ async function performShutdown(exitCode: number): Promise { shutdownTimeout, ); + // report writes are coalesced during a run, make sure none are outstanding + await withTimeout( + flushReports().catch((_error) => { + /** nothing do to here */ + }), + shutdownTimeout, + ); + // clear the restore file if it was a normal exit // 0 means it was a SIGNAL // 1 means crash -> keep the file diff --git a/apps/server/src/services/report-service/__tests__/report.store.test.ts b/apps/server/src/services/report-service/__tests__/report.store.test.ts index 9adf1dea1..8f7d9c258 100644 --- a/apps/server/src/services/report-service/__tests__/report.store.test.ts +++ b/apps/server/src/services/report-service/__tests__/report.store.test.ts @@ -52,6 +52,8 @@ const { renameReportsForProject, resetStore, getPathToReports, + flush, + hasUnwrittenChanges, } = await import('../report.store.js'); function makeRun(patch: Partial = {}): ShowRun { @@ -170,6 +172,52 @@ describe('upsertRuns()', () => { }); }); +describe('write coalescing', () => { + beforeEach(async () => { + await loadReports('project-a'); + }); + + it('keeps a debounced write off the disk until flushed', async () => { + await upsertRun(makeRun({ id: 'a' }), { debounce: true }); + + expect(hasUnwrittenChanges()).toBe(true); + expect(files.has(getPathToReports('project-a'))).toBe(false); + + await flush(); + + expect(hasUnwrittenChanges()).toBe(false); + const written = files.get(getPathToReports('project-a')) as { runs: ShowRun[] }; + expect(written.runs).toHaveLength(1); + }); + + it('collapses repeated debounced writes into one flush', async () => { + await upsertRun(makeRun({ id: 'a' }), { debounce: true }); + await upsertRun(makeRun({ id: 'b' }), { debounce: true }); + await upsertRun(makeRun({ id: 'c' }), { debounce: true }); + await flush(); + + const written = files.get(getPathToReports('project-a')) as { runs: ShowRun[] }; + expect(written.runs).toHaveLength(3); + }); + + it('writes straight through when not debounced', async () => { + await upsertRun(makeRun({ id: 'a' })); + + expect(hasUnwrittenChanges()).toBe(false); + expect(files.has(getPathToReports('project-a'))).toBe(true); + }); + + it('flushes pending work before switching project', async () => { + await upsertRun(makeRun({ id: 'a' }), { debounce: true }); + + await loadReports('project-b'); + + const written = files.get(getPathToReports('project-a')) as { runs: ShowRun[] }; + expect(written.runs).toHaveLength(1); + expect(getRuns()).toEqual([]); + }); +}); + describe('deleteRun()', () => { beforeEach(async () => { await loadReports('project-a'); diff --git a/apps/server/src/services/report-service/report.parser.ts b/apps/server/src/services/report-service/report.parser.ts index e8830ebda..14d2f597b 100644 --- a/apps/server/src/services/report-service/report.parser.ts +++ b/apps/server/src/services/report-service/report.parser.ts @@ -1,18 +1,17 @@ -import type { OntimeEventReport, ProjectReports, RunSummary, ShowRun } from 'ontime-types'; +import type { ProjectReports } from 'ontime-types'; import { is } from '../../utils/is.js'; /** - * Validates the contents of a report sidecar file. - * A file which fails validation is discarded rather than repaired: reports are - * a record, and a partially understood record is worse than an empty one. + * Shallow check on the contents of a report sidecar. + * + * We are the only writer of this file, so this guards against it being + * missing, empty or hand edited rather than against arbitrary payloads. + * Anything that fails is discarded and the project starts with no history, + * which is the same outcome as a first run. */ export function isProjectReports(value: unknown): value is ProjectReports { - if (!is.object(value)) { - return false; - } - - if (!is.objectWithKeys(value, ['runs'])) { + if (!is.object(value) || !is.objectWithKeys(value, ['runs'])) { return false; } @@ -20,111 +19,14 @@ export function isProjectReports(value: unknown): value is ProjectReports { return false; } - return value.runs.every(isShowRun); + return value.runs.every(isRunShaped); } -function isShowRun(value: unknown): value is ShowRun { - if (!is.object(value)) { +/** Enough of a run to be listed and opened without throwing */ +function isRunShaped(value: unknown): boolean { + if (!is.object(value) || !is.objectWithKeys(value, ['id', 'startedAt', 'report', 'summary'])) { return false; } - if ( - !is.objectWithKeys(value, [ - 'id', - 'rundownId', - 'rundownTitle', - 'label', - 'startedAt', - 'endedAt', - 'report', - 'summary', - ]) - ) { - return false; - } - - if (!is.string(value.id) || !is.string(value.rundownId) || !is.string(value.rundownTitle)) { - return false; - } - - if (!is.string(value.label)) { - return false; - } - - if (!is.number(value.startedAt)) { - return false; - } - - if (!is.number(value.endedAt) && value.endedAt !== null) { - return false; - } - - if (!isOntimeReport(value.report)) { - return false; - } - - return isRunSummary(value.summary); -} - -function isOntimeReport(value: unknown): value is Record { - if (!is.object(value)) { - return false; - } - - return Object.values(value).every(isEventReport); -} - -function isEventReport(value: unknown): value is OntimeEventReport { - if (!is.object(value)) { - return false; - } - - if (!is.objectWithKeys(value, ['startedAt', 'endedAt', 'scheduledStart', 'scheduledDuration', 'playCount'])) { - return false; - } - - if (!is.number(value.startedAt) && value.startedAt !== null) { - return false; - } - - if (!is.number(value.endedAt) && value.endedAt !== null) { - return false; - } - - return is.number(value.scheduledStart) && is.number(value.scheduledDuration) && is.number(value.playCount); -} - -function isRunSummary(value: unknown): value is RunSummary { - if (!is.object(value)) { - return false; - } - - const numericKeys = [ - 'eventsRun', - 'eventsPlanned', - 'scheduledDuration', - 'actualDuration', - 'drift', - 'eventsOver', - 'eventsUnder', - 'eventsOnTime', - ] as const; - - if (!is.objectWithKeys(value, [...numericKeys, 'worstOverrun'])) { - return false; - } - - if (!numericKeys.every((key) => is.number(value[key]))) { - return false; - } - - if (value.worstOverrun === null) { - return true; - } - - if (!is.object(value.worstOverrun) || !is.objectWithKeys(value.worstOverrun, ['id', 'delta'])) { - return false; - } - - return is.string(value.worstOverrun.id) && is.number(value.worstOverrun.delta); + return is.string(value.id) && is.number(value.startedAt) && is.object(value.report) && is.object(value.summary); } diff --git a/apps/server/src/services/report-service/report.store.ts b/apps/server/src/services/report-service/report.store.ts index 8ab409896..e7342c259 100644 --- a/apps/server/src/services/report-service/report.store.ts +++ b/apps/server/src/services/report-service/report.store.ts @@ -25,9 +25,19 @@ function emptyStore(): ProjectReports { return { runs: [] }; } +/** + * How long writes are coalesced for while a run is in progress. + * A run's events stop frequently and the sidecar holds the project's whole + * history, so writing per event would put work proportional to everything + * that ever ran onto the show critical path. + */ +const writeDebounce = 5000; + let fileRef: JSONFile | null = null; let cache: ProjectReports = emptyStore(); let failedWriteAttempts = 0; +let writeTimer: NodeJS.Timeout | null = null; +let hasPendingWrite = false; /** * Resolves the sidecar path for a given project file name @@ -42,6 +52,9 @@ export function getPathToReports(projectFilename: string): string { * project changes. */ export async function loadReports(projectFilename: string): Promise { + // the outgoing project may have a coalesced write outstanding + await flush(); + fileRef = new JSONFile(getPathToReports(projectFilename)); failedWriteAttempts = 0; @@ -67,19 +80,27 @@ export function getRun(id: string): ShowRun | undefined { return cache.runs.find((run) => run.id === id); } +export type WriteOptions = { + /** coalesce this write instead of hitting disk straight away */ + debounce?: boolean; +}; + /** * Inserts or replaces a run, keeping the list ordered newest first */ -export async function upsertRun(run: ShowRun): Promise { - return upsertRuns([run]); +export async function upsertRun(run: ShowRun, options?: WriteOptions): Promise { + return upsertRuns([run], options); } /** * Inserts or replaces several runs in one pass. * Batching matters: these all live in a single file, so writing per run would * mean concurrent writes racing on the same path. + * + * Writes are immediate unless the caller opts into debouncing. Anything driven + * by a user action should stay immediate so it cannot be lost. */ -export async function upsertRuns(updated: ShowRun[]): Promise { +export async function upsertRuns(updated: ShowRun[], options?: WriteOptions): Promise { if (updated.length === 0) { return; } @@ -92,7 +113,12 @@ export async function upsertRuns(updated: ShowRun[]): Promise { cache.runs[index] = run; } } - await persist(); + + if (options?.debounce) { + scheduleWrite(); + return; + } + await flush(); } /** @@ -106,7 +132,7 @@ export async function deleteRun(id: string): Promise { } cache.runs.splice(index, 1); - await persist(); + await flush(); return true; } @@ -120,7 +146,7 @@ export async function deleteRunsForRundown(rundownId: string): Promise { const removed = before - cache.runs.length; if (removed > 0) { - await persist(); + await flush(); } return removed; } @@ -130,7 +156,7 @@ export async function deleteRunsForRundown(rundownId: string): Promise { */ export async function deleteAllRuns(): Promise { cache.runs = []; - await persist(); + await flush(); } /** @@ -170,11 +196,34 @@ export async function renameReportsForProject(originalFilename: string, newFilen } /** - * Writes the cache to disk. - * Gives up after repeated failures so a broken disk cannot stall the runtime. + * Marks the cache dirty and arms the coalescing timer. * @private */ -async function persist(): Promise { +function scheduleWrite(): void { + hasPendingWrite = true; + if (writeTimer !== null) { + return; + } + + writeTimer = setTimeout(() => { + writeTimer = null; + void flush(); + }, writeDebounce); + // a pending report write is never a reason to keep the process alive + writeTimer.unref?.(); +} + +/** + * Writes any outstanding changes to disk now. + * Called on every immediate write, when the project changes, and on shutdown. + */ +export async function flush(): Promise { + if (writeTimer !== null) { + clearTimeout(writeTimer); + writeTimer = null; + } + hasPendingWrite = false; + if (fileRef === null || failedWriteAttempts > 3) { return; } @@ -187,10 +236,20 @@ async function persist(): Promise { } } +/** Whether a coalesced write is still outstanding, for tests and diagnostics */ +export function hasUnwrittenChanges(): boolean { + return hasPendingWrite; +} + /** * Resets in-memory state, used when no project is loaded and in tests */ export function resetStore(): void { + if (writeTimer !== null) { + clearTimeout(writeTimer); + writeTimer = null; + } + hasPendingWrite = false; fileRef = null; cache = emptyStore(); failedWriteAttempts = 0; diff --git a/apps/server/src/services/runtime-service/runtime.service.ts b/apps/server/src/services/runtime-service/runtime.service.ts index c03603b26..ee6d0de58 100644 --- a/apps/server/src/services/runtime-service/runtime.service.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -17,7 +17,7 @@ import { import { millisToString, validatePlayback } from 'ontime-utils'; import { triggerAutomations } from '../../api-data/automation/automation.service.js'; -import { closeRun, triggerReportEntry } from '../../api-data/report/report.service.js'; +import { triggerReportEntry } from '../../api-data/report/report.service.js'; import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api-data/rundown/rundown.dao.js'; import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; import { logger } from '../../classes/Logger.js'; @@ -523,8 +523,6 @@ class RuntimeService { logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStop, previousState); - // a full stop unloads the events, which is the operator ending the show - closeRun(); triggerAutomations(TimerLifeCycle.onStop); });