refactor(report): a run becomes a report only when finished

Progress is kept in memory while a show runs and written once, as a
complete report, when the operator finishes the run. The stored history
therefore never contains a partial run.

- Nothing writes on the cue path. triggerReportEntry only updates memory,
  so the sidecar is untouched between the first event and finish.
- Removes the write coalescing added for the previous model: with a
  single write per run there is nothing to debounce, so the timer, the
  dirty flag, the flush export and the shutdown hook all go, and app.ts
  returns to its previous state.
- ShowRun.endedAt is no longer nullable. A stored run is finished by
  definition, so recovery of runs left open by a crash is gone along with
  the concept, and the run list drops its ongoing state.
- Adds GET /report/runs/open for the editor indicator. /runs now means
  finished reports only, which is also the contract Cloud sees.
- A run whose rundown was deleted before it finished now reports the
  events that actually ran as its planned count, rather than zero.

Known gap: RestorePoint carries playback state only, so a crash mid show
loses the in progress report. The stored history is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nr3FbLbM8gB8Jm771YgTV
This commit is contained in:
Claude
2026-08-08 21:35:52 +00:00
parent faca1a1c76
commit b21385ec4a
13 changed files with 152 additions and 388 deletions
+1
View File
@@ -21,6 +21,7 @@ export const CSS_OVERRIDE = ['cssOverride'];
export const CLIENT_LIST = ['clientList']; export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report']; export const REPORT = ['report'];
export const REPORT_RUNS = ['report', 'runs']; export const REPORT_RUNS = ['report', 'runs'];
export const REPORT_OPEN_RUN = ['report', 'runs', 'open'];
export const TRANSLATION = ['translation']; export const TRANSLATION = ['translation'];
// API URLs // API URLs
+18 -2
View File
@@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { OntimeReport, ShowRun, ShowRunSummary } from 'ontime-types'; import { OntimeReport, OpenRun, ShowRun, ShowRunSummary } from 'ontime-types';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { REPORT, apiEntryUrl } from './constants'; import { REPORT, apiEntryUrl } from './constants';
@@ -45,7 +45,23 @@ export async function fetchRun(id: string, options?: RequestOptions): Promise<Sh
} }
/** /**
* HTTP request to close the run in progress, writing it to history immediately * HTTP request to fetch the run being recorded
* @returns null when no run is in progress
*/
export async function fetchOpenRun(options?: RequestOptions): Promise<OpenRun | null> {
try {
const res = await axios.get(`${reportUrl}/runs/open`, { signal: options?.signal });
return res.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
}
/**
* HTTP request to finish the run in progress, which is what writes it to history
*/ */
export async function finishRun(): Promise<void> { export async function finishRun(): Promise<void> {
await axios.post(`${reportUrl}/runs/finish`); await axios.post(`${reportUrl}/runs/finish`);
+17 -7
View File
@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ShowRun, ShowRunSummary } from 'ontime-types'; import { OpenRun, ShowRun, ShowRunSummary } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils'; import { MILLIS_PER_HOUR } from 'ontime-utils';
import { REPORT_RUNS } from '../api/constants'; import { REPORT_OPEN_RUN, REPORT_RUNS } from '../api/constants';
import { fetchRun, fetchRuns } from '../api/report'; import { fetchOpenRun, fetchRun, fetchRuns } from '../api/report';
/** /**
* Run history for the current project, optionally scoped to a rundown * Run history for the current project, optionally scoped to a rundown
@@ -33,8 +33,18 @@ export function useRun(id: string | null) {
return { data, status }; return { data, status };
} }
/** The run currently in progress, if any */ /**
export function useOpenRun(): ShowRunSummary | null { * The run currently being recorded, if any.
const { data } = useRuns(); * A run in progress lives in memory on the server until it is finished, so it
return data.find((run) => run.endedAt === null) ?? null; * cannot be found in the run history and needs its own query.
*/
export function useOpenRun(): OpenRun | null {
const { data } = useQuery<OpenRun | null>({
queryKey: REPORT_OPEN_RUN,
queryFn: ({ signal }) => fetchOpenRun({ signal }),
placeholderData: (previousData, _previousQuery) => previousData,
staleTime: MILLIS_PER_HOUR,
});
return data ?? null;
} }
@@ -42,8 +42,8 @@ export default function ReportSettings() {
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Paragraph> <Panel.Paragraph>
A run is created the first time an event is started, and is added to history once playback stops. Every Recording starts with the first event of a show and is saved here when you finish the run. Every report
run keeps its own snapshot of the schedule, so editing the rundown later does not change past reports. keeps its own snapshot of the schedule, so editing the rundown later does not change past reports.
</Panel.Paragraph> </Panel.Paragraph>
<RunsList runs={runs} selectedRunId={selectedRunId} onSelect={setSelectedRunId} /> <RunsList runs={runs} selectedRunId={selectedRunId} onSelect={setSelectedRunId} />
</Panel.Card> </Panel.Card>
@@ -6,7 +6,6 @@ import { deleteRun, renameRun } from '../../../../../common/api/report';
import { maybeAxiosError } from '../../../../../common/api/utils'; import { maybeAxiosError } from '../../../../../common/api/utils';
import IconButton from '../../../../../common/components/buttons/IconButton'; import IconButton from '../../../../../common/components/buttons/IconButton';
import Input from '../../../../../common/components/input/input/Input'; import Input from '../../../../../common/components/input/input/Input';
import Tag from '../../../../../common/components/tag/Tag';
import { preventEscape } from '../../../../../common/utils/keyEvent'; import { preventEscape } from '../../../../../common/utils/keyEvent';
import { cx } from '../../../../../common/utils/styleUtils'; import { cx } from '../../../../../common/utils/styleUtils';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
@@ -77,14 +76,13 @@ export default function RunsList({ runs, selectedRunId, onSelect }: RunsListProp
<th>Started</th> <th>Started</th>
<th>Drift</th> <th>Drift</th>
<th /> <th />
<th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{runs.length === 0 && ( {runs.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty
title='No runs yet' title='No reports yet'
description='A run is created the first time an event is started, and is added to history once playback stops.' description='Recording starts with the first event of a show. Use Finish run in the editor to save the report here.'
/> />
)} )}
{runs.map((run) => { {runs.map((run) => {
@@ -123,7 +121,6 @@ export default function RunsList({ runs, selectedRunId, onSelect }: RunsListProp
<td>{run.rundownTitle}</td> <td>{run.rundownTitle}</td>
<td>{new Date(run.startedAt).toLocaleString()}</td> <td>{new Date(run.startedAt).toLocaleString()}</td>
<td>{formatDrift(run.summary.drift, run.summary.eventsRun)}</td> <td>{formatDrift(run.summary.drift, run.summary.eventsRun)}</td>
<td>{run.endedAt === null && <Tag variant='active'>Ongoing</Tag>}</td>
<Panel.InlineElements align='end' relation='inner' as='td' onClick={(event) => event.stopPropagation()}> <Panel.InlineElements align='end' relation='inner' as='td' onClick={(event) => event.stopPropagation()}>
{!isRenaming && ( {!isRenaming && (
<> <>
@@ -7,8 +7,8 @@ import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.moc
// in-memory stand-in for the sidecar store, verified separately in report.store.test.ts // in-memory stand-in for the sidecar store, verified separately in report.store.test.ts
let runs: ShowRun[] = []; let runs: ShowRun[] = [];
/** write options seen by the store, so we can assert what reaches the disk and when */ /** how many times the store was asked to write, to prove the cue path stays clean */
let writeOptions: ({ debounce?: boolean } | undefined)[] = []; let writes = 0;
vi.mock('../../../services/report-service/report.store.js', () => ({ vi.mock('../../../services/report-service/report.store.js', () => ({
// isolation between tests comes from the top-level beforeEach resetting `runs`, // isolation between tests comes from the top-level beforeEach resetting `runs`,
@@ -16,8 +16,8 @@ vi.mock('../../../services/report-service/report.store.js', () => ({
loadReports: vi.fn(async () => ({ runs })), loadReports: vi.fn(async () => ({ runs })),
getRuns: vi.fn(() => runs), getRuns: vi.fn(() => runs),
getRun: vi.fn((id: string) => runs.find((run) => run.id === id)), getRun: vi.fn((id: string) => runs.find((run) => run.id === id)),
upsertRun: vi.fn(async (run: ShowRun, options?: { debounce?: boolean }) => { upsertRun: vi.fn(async (run: ShowRun) => {
writeOptions.push(options); writes += 1;
const index = runs.findIndex((candidate) => candidate.id === run.id); const index = runs.findIndex((candidate) => candidate.id === run.id);
if (index === -1) { if (index === -1) {
runs.unshift(run); runs.unshift(run);
@@ -25,16 +25,6 @@ vi.mock('../../../services/report-service/report.store.js', () => ({
runs[index] = run; runs[index] = run;
} }
}), }),
upsertRuns: vi.fn(async (updated: ShowRun[]) => {
for (const run of updated) {
const index = runs.findIndex((candidate) => candidate.id === run.id);
if (index === -1) {
runs.unshift(run);
} else {
runs[index] = run;
}
}
}),
deleteRun: vi.fn(async (id: string) => { deleteRun: vi.fn(async (id: string) => {
const index = runs.findIndex((run) => run.id === id); const index = runs.findIndex((run) => run.id === id);
if (index === -1) return false; if (index === -1) return false;
@@ -77,6 +67,7 @@ const {
initReports, initReports,
listRuns, listRuns,
getRun, getRun,
getOpenRun,
renameRun, renameRun,
deleteRun, deleteRun,
deleteAllRuns, deleteAllRuns,
@@ -87,7 +78,7 @@ const eventB = makeOntimeEvent({ id: 'event-b', timeStart: 10000, timeEnd: 20000
beforeEach(async () => { beforeEach(async () => {
runs = []; runs = [];
writeOptions = []; writes = 0;
currentRundown = makeRundown({ currentRundown = makeRundown({
id: 'rundown-1', id: 'rundown-1',
title: 'Test rundown', title: 'Test rundown',
@@ -155,59 +146,59 @@ describe('triggerReportEntry()', () => {
expect(generate()).toEqual({}); expect(generate()).toEqual({});
}); });
it('persists a run to history on the first event stop', async () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
const stop = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 10000 });
triggerReportEntry(TimerLifeCycle.onStop, stop);
// persistence happens off the event loop
await new Promise((resolve) => setImmediate(resolve));
expect(listRuns()).toHaveLength(1);
expect(listRuns()[0].rundownId).toBe('rundown-1');
});
}); });
describe('write pressure on the show critical path', () => { describe('nothing is written on the cue path', () => {
function runEvent(clock: number) { function runEvent(clock: number) {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock, _startEpoch: showEpoch }); const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock, _startEpoch: showEpoch });
triggerReportEntry(TimerLifeCycle.onStart, state); triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: clock + 1000 } as typeof state); triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: clock + 1000 } as typeof state);
} }
it('coalesces writes while a run is in progress', async () => { it('keeps a run in progress out of history until it is finished', 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(0);
runEvent(2000); runEvent(2000);
runEvent(4000); runEvent(4000);
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
expect(writeOptions.length).toBeGreaterThan(0); // the run exists and is accumulating, but is not a report yet
expect(writeOptions.every((options) => options?.debounce === true)).toBe(true); expect(getOpenRun()).not.toBeNull();
expect(Object.keys(generate())).toHaveLength(1);
expect(listRuns()).toHaveLength(0);
expect(writes).toBe(0);
}); });
it('writes immediately when the run is finished', async () => { it('writes exactly once, when the run is finished', async () => {
runEvent(0); runEvent(0);
await new Promise((resolve) => setImmediate(resolve)); runEvent(2000);
writeOptions = []; expect(writes).toBe(0);
await closeRun(); await closeRun();
expect(writeOptions).toHaveLength(1); expect(writes).toBe(1);
expect(writeOptions[0]?.debounce).toBeFalsy(); expect(listRuns()).toHaveLength(1);
expect(getOpenRun()).toBeNull();
}); });
it('writes immediately when a user renames a run', async () => { it('stamps the finished run with an end', async () => {
const before = Date.now();
runEvent(0); runEvent(0);
await closeRun(); await closeRun();
const runId = listRuns()[0].id;
writeOptions = [];
await renameRun(runId, 'Dress rehearsal'); const run = listRuns()[0];
expect(run.endedAt).toBeGreaterThanOrEqual(before);
expect(run.endedAt).toBeGreaterThanOrEqual(run.startedAt);
});
expect(writeOptions[0]?.debounce).toBeFalsy(); it('discards an unfinished run when the project changes', async () => {
runEvent(0);
expect(getOpenRun()).not.toBeNull();
await initReports('project-b');
expect(getOpenRun()).toBeNull();
expect(listRuns()).toHaveLength(0);
expect(writes).toBe(0);
}); });
}); });
@@ -224,7 +215,7 @@ describe('run timestamps', () => {
}); });
triggerReportEntry(TimerLifeCycle.onStart, state); triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 44200000 } as typeof state); triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 44200000 } as typeof state);
await new Promise((resolve) => setImmediate(resolve)); await closeRun();
const run = listRuns()[0]; const run = listRuns()[0];
expect(run.startedAt).toBe(showEpoch); expect(run.startedAt).toBe(showEpoch);
@@ -236,7 +227,7 @@ describe('run timestamps', () => {
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: null }); const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: null });
triggerReportEntry(TimerLifeCycle.onStart, state); triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state); triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state);
await new Promise((resolve) => setImmediate(resolve)); await closeRun();
const run = listRuns()[0]; const run = listRuns()[0];
expect(run.startedAt).toBeGreaterThanOrEqual(before); expect(run.startedAt).toBeGreaterThanOrEqual(before);
@@ -277,16 +268,14 @@ describe('closeRun()', () => {
triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start); triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start);
await closeRun(); await closeRun();
await new Promise((resolve) => setImmediate(resolve));
expect(listRuns()).toHaveLength(1); expect(listRuns()).toHaveLength(1);
expect(listRuns()[0].endedAt).toBe(10000);
// a new start after closing opens a second run rather than reusing the first // a new start after finishing opens a second run rather than reusing the first
const secondStart = makeRuntimeStateData({ eventNow: eventB, timer: { startedAt: 20000 }, clock: 20000 }); const secondStart = makeRuntimeStateData({ eventNow: eventB, timer: { startedAt: 20000 }, clock: 20000 });
triggerReportEntry(TimerLifeCycle.onStart, secondStart); triggerReportEntry(TimerLifeCycle.onStart, secondStart);
triggerReportEntry(TimerLifeCycle.onStop, { ...secondStart, clock: 30000 } as typeof secondStart); triggerReportEntry(TimerLifeCycle.onStop, { ...secondStart, clock: 30000 } as typeof secondStart);
await new Promise((resolve) => setImmediate(resolve)); await closeRun();
expect(listRuns()).toHaveLength(2); expect(listRuns()).toHaveLength(2);
}); });
@@ -297,81 +286,6 @@ describe('closeRun()', () => {
}); });
}); });
describe('initReports()', () => {
it('closes a dangling run left open by a crash or shutdown', async () => {
runs = [
{
id: 'dangling',
rundownId: 'rundown-1',
rundownTitle: 'Test rundown',
label: 'unfinished',
startedAt: 0,
endedAt: null,
report: {
[eventA.id]: {
startedAt: 0,
endedAt: 9000,
scheduledStart: 0,
scheduledDuration: 10000,
playCount: 1,
},
},
summary: {
eventsRun: 1,
eventsPlanned: 2,
scheduledDuration: 10000,
actualDuration: 9000,
drift: -1000,
eventsOver: 0,
eventsUnder: 1,
eventsOnTime: 0,
worstOverrun: null,
},
},
];
await initReports('project-a');
const recovered = getRun('dangling');
expect(recovered?.endedAt).toBe(9000);
});
it('closes every dangling run, not only the first', async () => {
runs = [makeDanglingRun('first', 4000), makeDanglingRun('second', 7000)];
await initReports('project-a');
expect(getRun('first')?.endedAt).toBe(4000);
expect(getRun('second')?.endedAt).toBe(7000);
expect(listRuns().every((run) => run.endedAt !== null)).toBe(true);
});
function makeDanglingRun(id: string, endedAt: number): ShowRun {
return {
id,
rundownId: 'rundown-1',
rundownTitle: 'Test rundown',
label: id,
startedAt: showEpoch,
endedAt: null,
report: {
[eventA.id]: { startedAt: 0, endedAt, scheduledStart: 0, scheduledDuration: 10000, playCount: 1 },
},
summary: {
eventsRun: 1,
eventsPlanned: 2,
scheduledDuration: 10000,
actualDuration: endedAt,
drift: endedAt - 10000,
eventsOver: 0,
eventsUnder: 1,
eventsOnTime: 0,
worstOverrun: null,
},
};
}
});
describe('summary is measured against the run\'s own rundown', () => { describe('summary is measured against the run\'s own rundown', () => {
it('counts planned events from the rundown the run belongs to', async () => { it('counts planned events from the rundown the run belongs to', async () => {
// a three event rundown that is not the loaded one // a three event rundown that is not the loaded one
@@ -390,29 +304,26 @@ describe('summary is measured against the run\'s own rundown', () => {
triggerReportEntry(TimerLifeCycle.onStart, state); triggerReportEntry(TimerLifeCycle.onStart, state);
currentRundown = storedRundowns['rundown-1']; currentRundown = storedRundowns['rundown-1'];
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state); await closeRun();
await new Promise((resolve) => setImmediate(resolve));
// three, from rundown-2, not two from the now loaded rundown-1 // three, from rundown-2, not two from the now loaded rundown-1
expect(listRuns()[0].summary.eventsPlanned).toBe(3); expect(listRuns()[0].summary.eventsPlanned).toBe(3);
}); });
it('keeps the last known count when the rundown has been deleted', async () => { it('falls back to what ran when the rundown has been deleted', async () => {
// the run belongs to a rundown that is neither loaded nor in storage
currentRundown = makeRundown({ id: 'gone', title: 'Deleted rundown', entries: {}, order: [], flatOrder: [] }); currentRundown = makeRundown({ id: 'gone', title: 'Deleted rundown', entries: {}, order: [], flatOrder: [] });
const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: showEpoch }); const state = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0, _startEpoch: showEpoch });
triggerReportEntry(TimerLifeCycle.onStart, state); triggerReportEntry(TimerLifeCycle.onStart, state);
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state); triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 10000 } as typeof state);
await new Promise((resolve) => setImmediate(resolve));
// rundown disappears from both the loaded slot and storage
const runId = listRuns()[0].id;
runs[0] = { ...runs[0], summary: { ...runs[0].summary, eventsPlanned: 7 } };
currentRundown = storedRundowns['rundown-1']; currentRundown = storedRundowns['rundown-1'];
triggerReportEntry(TimerLifeCycle.onStop, { ...state, clock: 20000 } as typeof state); await closeRun();
await new Promise((resolve) => setImmediate(resolve));
expect(getRun(runId)?.summary.eventsPlanned).toBe(7); // one event ran, so planned reads as one rather than zero of one
const summary = listRuns()[0].summary;
expect(summary.eventsPlanned).toBe(1);
expect(summary.eventsRun).toBe(1);
}); });
}); });
@@ -456,17 +367,6 @@ describe('run history queries and edits', () => {
expect(getRun('run-a')).toBeUndefined(); expect(getRun('run-a')).toBeUndefined();
}); });
it('clears the in-progress report when the open run is deleted', async () => {
const start = makeRuntimeStateData({ eventNow: eventA, timer: { startedAt: 0 }, clock: 0 });
triggerReportEntry(TimerLifeCycle.onStart, start);
triggerReportEntry(TimerLifeCycle.onStop, { ...start, clock: 10000 } as typeof start);
await new Promise((resolve) => setImmediate(resolve));
const openRunId = listRuns()[0].id;
await deleteRun(openRunId);
expect(generate()).toEqual({});
});
it('deletes all run history', async () => { it('deletes all run history', async () => {
await makeClosedRun('run-a'); await makeClosedRun('run-a');
@@ -16,13 +16,27 @@ router.get('/', (_req: Request, res: Response) => {
}); });
/** /**
* Run history, most recent first. `?rundownId=` scopes the list to one rundown. * Finished reports, most recent first. `?rundownId=` scopes the list to one
* rundown. A run in progress is not a report and does not appear here.
*/ */
router.get('/runs', validateRundownIdQuery, (req: Request, res: Response) => { router.get('/runs', validateRundownIdQuery, (req: Request, res: Response) => {
const { rundownId } = req.query as { rundownId?: string }; const { rundownId } = req.query as { rundownId?: string };
res.status(200).json(report.listRuns(rundownId)); res.status(200).json(report.listRuns(rundownId));
}); });
/**
* The run currently being recorded, which exists in memory only.
* Registered ahead of /runs/:id so "open" is not read as an id.
*/
router.get('/runs/open', (_req: Request, res: Response) => {
const run = report.getOpenRun();
if (!run) {
res.status(404).send();
return;
}
res.status(200).json(run);
});
/** /**
* Closes the run in progress and writes it to history immediately. * Closes the run in progress and writes it to history immediately.
* Registered ahead of /runs/:id so "finish" is not read as an id. * Registered ahead of /runs/:id so "finish" is not read as an id.
@@ -2,6 +2,7 @@ import {
EntryId, EntryId,
OntimeEventReport, OntimeEventReport,
OntimeReport, OntimeReport,
OpenRun,
RefetchKey, RefetchKey,
Rundown, Rundown,
ShowRun, ShowRun,
@@ -23,8 +24,8 @@ const report = new Map<EntryId, OntimeEventReport>();
let formattedReport: OntimeReport | null = null; let formattedReport: OntimeReport | null = null;
/** metadata for the run in progress, null when no run is open */ /** the run being recorded, held in memory only until it is finished */
let openRun: Omit<ShowRun, 'report' | 'summary'> | null = null; let openRun: OpenRun | null = null;
/** /**
* generates a full report * generates a full report
@@ -48,7 +49,6 @@ export function clear(id?: string) {
} else { } else {
report.clear(); report.clear();
} }
void persistOpenRun();
} }
/** /**
@@ -102,7 +102,8 @@ export function triggerReportEntry(
playCount: previous?.playCount ?? 1, playCount: previous?.playCount ?? 1,
}); });
formattedReport = null; formattedReport = null;
void persistOpenRun(); // deliberately not written here: a run is kept in memory until it is
// finished, so nothing touches the disk on the cue path
sendRefetch(RefetchKey.Report); sendRefetch(RefetchKey.Report);
} }
} }
@@ -122,7 +123,7 @@ export async function closeRun(): Promise<ShowRun | null> {
// detach the run before the write so a start arriving in between opens a // 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 // new run instead of appending to the one we are closing
const closing = { ...openRun, endedAt: lastEndedAtIn(generate()) }; const closing = { ...openRun, endedAt: timeCore.now() };
openRun = null; openRun = null;
const closed = await persistRun(closing, generate()); const closed = await persistRun(closing, generate());
@@ -130,6 +131,11 @@ export async function closeRun(): Promise<ShowRun | null> {
return closed; return closed;
} }
/** The run being recorded, if any. Held in memory until it is finished. */
export function getOpenRun(): OpenRun | null {
return openRun;
}
/** /**
* Opens a run on the first event start after the previous run closed. * Opens a run on the first event start after the previous run closed.
* The in progress report is reset here rather than on close, so the rundown * The in progress report is reset here rather than on close, so the rundown
@@ -156,7 +162,6 @@ function openRunIfNeeded(state: DeepReadonly<RuntimeState>) {
rundownTitle: rundown.title, rundownTitle: rundown.title,
label: makeRunLabel(startedAt), label: makeRunLabel(startedAt),
startedAt, startedAt,
endedAt: null,
}; };
// let the editor show that a run is being recorded without waiting // let the editor show that a run is being recorded without waiting
@@ -181,29 +186,21 @@ function makeRunLabel(startedAt: number): string {
* Persisting on every event stop means an interrupted show still leaves a record. * Persisting on every event stop means an interrupted show still leaves a record.
* @private * @private
*/ */
async function persistOpenRun(): Promise<void> {
if (openRun === null) {
return;
}
// 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 * Writes a finished run and its derived summary to the sidecar
* @private * @private
*/ */
async function persistRun( async function persistRun(
run: Omit<ShowRun, 'report' | 'summary'>, run: Omit<ShowRun, 'report' | 'summary'>,
currentReport: OntimeReport, currentReport: OntimeReport,
options?: reportStore.WriteOptions,
): Promise<ShowRun> { ): Promise<ShowRun> {
const rundown = getRundownForRun(run.rundownId); const rundown = getRundownForRun(run.rundownId);
// a rundown deleted mid-run leaves nothing to count against, so we keep // a rundown deleted mid-run leaves nothing to count against. Falling back to
// whatever the run was last written with rather than reporting zero // what actually ran keeps the report honest, where zero would read as if the
// whole show had been missed
const eventsPlanned = rundown const eventsPlanned = rundown
? countPlannedEvents(rundown.entries, rundown.flatOrder) ? countPlannedEvents(rundown.entries, rundown.flatOrder)
: (reportStore.getRun(run.id)?.summary.eventsPlanned ?? 0); : Object.keys(currentReport).length;
const persisted: ShowRun = { const persisted: ShowRun = {
...run, ...run,
@@ -211,7 +208,7 @@ async function persistRun(
summary: getRunSummary(currentReport, eventsPlanned), summary: getRunSummary(currentReport, eventsPlanned),
}; };
await reportStore.upsertRun(persisted, options); await reportStore.upsertRun(persisted);
return persisted; return persisted;
} }
@@ -236,8 +233,10 @@ function getRundownForRun(rundownId: string): Readonly<Rundown> | null {
/** /**
* Prepares reporting for a newly loaded project. * Prepares reporting for a newly loaded project.
* Any run left open by a crash or shutdown is closed against its own data *
* so it cannot absorb events from the next show. * There is nothing to recover here: a run only reaches the sidecar once it
* has been finished, so the stored history never contains a partial run. An
* unfinished run is discarded along with the project it belonged to.
*/ */
export async function initReports(projectFilename: string): Promise<void> { export async function initReports(projectFilename: string): Promise<void> {
report.clear(); report.clear();
@@ -245,29 +244,6 @@ export async function initReports(projectFilename: string): Promise<void> {
openRun = null; openRun = null;
await reportStore.loadReports(projectFilename); await reportStore.loadReports(projectFilename);
// close every run left open, not just the first: a run stuck with a null
// endedAt reads as ongoing forever and is skipped by getLatestRun
const closed: ShowRun[] = [];
for (const run of reportStore.getRuns()) {
if (run.endedAt !== null) continue;
closed.push({ ...run, endedAt: lastEndedAtIn(run.report) });
}
await reportStore.upsertRuns(closed);
}
/**
* Timestamp of the last event to finish within a stored report
* @private
*/
function lastEndedAtIn(storedReport: OntimeReport): number | null {
let latest: number | null = null;
for (const entry of Object.values(storedReport)) {
if (entry.endedAt !== null && (latest === null || entry.endedAt > latest)) {
latest = entry.endedAt;
}
}
return latest;
} }
/** Run history for the current project, without per event data */ /** Run history for the current project, without per event data */
@@ -295,13 +271,10 @@ export async function renameRun(id: string, label: string): Promise<ShowRun | un
} }
export async function deleteRun(id: string): Promise<boolean> { export async function deleteRun(id: string): Promise<boolean> {
// only finished reports are in the store, so this can never target the
// run in progress
const didDelete = await reportStore.deleteRun(id); const didDelete = await reportStore.deleteRun(id);
if (didDelete) { if (didDelete) {
if (openRun?.id === id) {
openRun = null;
report.clear();
formattedReport = null;
}
sendRefetch(RefetchKey.Report); sendRefetch(RefetchKey.Report);
} }
return didDelete; return didDelete;
-9
View File
@@ -28,7 +28,6 @@ import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js'; import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
import * as messageService from './services/message-service/message.service.js'; import * as messageService from './services/message-service/message.service.js';
import { initialiseProject } from './services/project-service/ProjectService.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 { restoreService } from './services/restore-service/restore.service.js';
import type { RestorePoint } from './services/restore-service/restore.type.js'; import type { RestorePoint } from './services/restore-service/restore.type.js';
import { runtimeService } from './services/runtime-service/runtime.service.js'; import { runtimeService } from './services/runtime-service/runtime.service.js';
@@ -332,14 +331,6 @@ async function performShutdown(exitCode: number): Promise<void> {
shutdownTimeout, 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 // clear the restore file if it was a normal exit
// 0 means it was a SIGNAL // 0 means it was a SIGNAL
// 1 means crash -> keep the file // 1 means crash -> keep the file
@@ -44,7 +44,6 @@ const {
getRuns, getRuns,
getRun, getRun,
upsertRun, upsertRun,
upsertRuns,
deleteRun, deleteRun,
deleteRunsForRundown, deleteRunsForRundown,
deleteAllRuns, deleteAllRuns,
@@ -52,8 +51,6 @@ const {
renameReportsForProject, renameReportsForProject,
resetStore, resetStore,
getPathToReports, getPathToReports,
flush,
hasUnwrittenChanges,
} = await import('../report.store.js'); } = await import('../report.store.js');
function makeRun(patch: Partial<ShowRun> = {}): ShowRun { function makeRun(patch: Partial<ShowRun> = {}): ShowRun {
@@ -143,80 +140,7 @@ describe('upsertRun() / getRun() / getRuns()', () => {
}); });
}); });
describe('upsertRuns()', () => {
beforeEach(async () => {
await loadReports('project-a');
});
it('applies several runs in a single write', async () => {
await upsertRuns([makeRun({ id: 'a' }), makeRun({ id: 'b' })]);
expect(getRuns().map((run) => run.id)).toEqual(['b', 'a']);
const written = files.get(getPathToReports('project-a')) as { runs: ShowRun[] };
expect(written.runs).toHaveLength(2);
});
it('mixes inserts and replacements', async () => {
await upsertRun(makeRun({ id: 'existing', label: 'before' }));
await upsertRuns([makeRun({ id: 'existing', label: 'after' }), makeRun({ id: 'fresh' })]);
expect(getRuns()).toHaveLength(2);
expect(getRun('existing')?.label).toBe('after');
});
it('does not write when given nothing to do', async () => {
await upsertRun(makeRun({ id: 'a' }));
await upsertRuns([]);
expect(getRuns()).toHaveLength(1);
});
});
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()', () => { describe('deleteRun()', () => {
beforeEach(async () => { beforeEach(async () => {
@@ -25,19 +25,9 @@ function emptyStore(): ProjectReports {
return { runs: [] }; 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<ProjectReports> | null = null; let fileRef: JSONFile<ProjectReports> | null = null;
let cache: ProjectReports = emptyStore(); let cache: ProjectReports = emptyStore();
let failedWriteAttempts = 0; let failedWriteAttempts = 0;
let writeTimer: NodeJS.Timeout | null = null;
let hasPendingWrite = false;
/** /**
* Resolves the sidecar path for a given project file name * Resolves the sidecar path for a given project file name
@@ -52,9 +42,6 @@ export function getPathToReports(projectFilename: string): string {
* project changes. * project changes.
*/ */
export async function loadReports(projectFilename: string): Promise<ProjectReports> { export async function loadReports(projectFilename: string): Promise<ProjectReports> {
// the outgoing project may have a coalesced write outstanding
await flush();
fileRef = new JSONFile<ProjectReports>(getPathToReports(projectFilename)); fileRef = new JSONFile<ProjectReports>(getPathToReports(projectFilename));
failedWriteAttempts = 0; failedWriteAttempts = 0;
@@ -80,45 +67,20 @@ export function getRun(id: string): ShowRun | undefined {
return cache.runs.find((run) => run.id === id); 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 * Inserts or replaces a run, keeping the list ordered newest first.
*/
export async function upsertRun(run: ShowRun, options?: WriteOptions): Promise<void> {
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 * Every write here is a whole finished report, which only happens when a run
* by a user action should stay immediate so it cannot be lost. * is finished or edited. Nothing writes on the cue path.
*/ */
export async function upsertRuns(updated: ShowRun[], options?: WriteOptions): Promise<void> { export async function upsertRun(run: ShowRun): Promise<void> {
if (updated.length === 0) { const index = cache.runs.findIndex((candidate) => candidate.id === run.id);
return; if (index === -1) {
cache.runs.unshift(run);
} else {
cache.runs[index] = run;
} }
await persist();
for (const run of updated) {
const index = cache.runs.findIndex((candidate) => candidate.id === run.id);
if (index === -1) {
cache.runs.unshift(run);
} else {
cache.runs[index] = run;
}
}
if (options?.debounce) {
scheduleWrite();
return;
}
await flush();
} }
/** /**
@@ -132,7 +94,7 @@ export async function deleteRun(id: string): Promise<boolean> {
} }
cache.runs.splice(index, 1); cache.runs.splice(index, 1);
await flush(); await persist();
return true; return true;
} }
@@ -146,7 +108,7 @@ export async function deleteRunsForRundown(rundownId: string): Promise<number> {
const removed = before - cache.runs.length; const removed = before - cache.runs.length;
if (removed > 0) { if (removed > 0) {
await flush(); await persist();
} }
return removed; return removed;
} }
@@ -156,7 +118,7 @@ export async function deleteRunsForRundown(rundownId: string): Promise<number> {
*/ */
export async function deleteAllRuns(): Promise<void> { export async function deleteAllRuns(): Promise<void> {
cache.runs = []; cache.runs = [];
await flush(); await persist();
} }
/** /**
@@ -196,34 +158,11 @@ export async function renameReportsForProject(originalFilename: string, newFilen
} }
/** /**
* Marks the cache dirty and arms the coalescing timer. * Writes the cache to disk.
* Gives up after repeated failures so a broken disk cannot stall the runtime.
* @private * @private
*/ */
function scheduleWrite(): void { async function persist(): Promise<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<void> {
if (writeTimer !== null) {
clearTimeout(writeTimer);
writeTimer = null;
}
hasPendingWrite = false;
if (fileRef === null || failedWriteAttempts > 3) { if (fileRef === null || failedWriteAttempts > 3) {
return; return;
} }
@@ -236,20 +175,10 @@ export async function flush(): Promise<void> {
} }
} }
/** 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 * Resets in-memory state, used when no project is loaded and in tests
*/ */
export function resetStore(): void { export function resetStore(): void {
if (writeTimer !== null) {
clearTimeout(writeTimer);
writeTimer = null;
}
hasPendingWrite = false;
fileRef = null; fileRef = null;
cache = emptyStore(); cache = emptyStore();
failedWriteAttempts = 0; failedWriteAttempts = 0;
@@ -49,10 +49,11 @@ export type ShowRun = {
*/ */
startedAt: number; startedAt: number;
/** /**
* Time of day the last event in the run finished, or null while the run is * Wall clock instant the run was finished.
* open. Only ever compared against the per event times in the same run. * Always set: a run only becomes a report once it has been finished, so
* there is no such thing as a stored run still in progress.
*/ */
endedAt: MaybeNumber; endedAt: number;
report: OntimeReport; report: OntimeReport;
summary: RunSummary; summary: RunSummary;
}; };
@@ -60,6 +61,13 @@ export type ShowRun = {
/** A run without its per event data, for list views */ /** A run without its per event data, for list views */
export type ShowRunSummary = Omit<ShowRun, 'report'>; export type ShowRunSummary = Omit<ShowRun, 'report'>;
/**
* The run currently being recorded.
* Lives in memory only until it is finished, so it carries no report data
* and has no end.
*/
export type OpenRun = Omit<ShowRun, 'report' | 'summary' | 'endedAt'>;
/** Contents of a project's report sidecar file */ /** Contents of a project's report sidecar file */
export type ProjectReports = { export type ProjectReports = {
runs: ShowRun[]; runs: ShowRun[];
+1
View File
@@ -27,6 +27,7 @@ export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Tempo
export type { export type {
OntimeReport, OntimeReport,
OntimeEventReport, OntimeEventReport,
OpenRun,
ProjectReports, ProjectReports,
RunSummary, RunSummary,
ShowRun, ShowRun,