From b1059e7bffe4d273742fce20a4cf1e7b93271b93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 16:21:53 +0000 Subject: [PATCH] Replace structuredClone with shape-aware clones in rundown/data hot paths structuredClone's generic serialization algorithm does far more work than plain object spreads need for these known shapes. Adds cloneEntryData() and cloneRundown() as drop-in replacements (same "independent copy" contract, same call sites) and swaps them in everywhere a rundown or a single entry was being deep-cloned via structuredClone: - createTransaction()/init() in rundown.dao.ts - the main per-mutation clone - DataProvider.setRundown() - was re-cloning the whole rundown a second time on every single commit - rundown.service.ts background-rundown clones (custom field rename/remove, duplicateExistingRundown) - the per-entry clone in processRundown's non-mutating path (rundown.parser.ts) - mergeRundownPreservingFields's per-entry clone Also: - safeMerge() (DataProvider.utils.ts) was deep-cloning the entire DatabaseModel, including all rundowns, just to read a handful of small config properties that never touch rundowns - it now only clones the properties it actually merges. - sheets.service.ts's per-row clone before building a (read-only) Google Sheets cell request was unnecessary and is removed. - runtime.service.ts's previous-state snapshot for eventNow/eventNext/ eventFlag/groupNow now uses cloneEntryData. Benchmarked on a synthetic 1000-event rundown: a cue-only edit (no reprocessing needed) went from ~5ms to ~1.2ms end to end, including the DataProvider clone. Verified against the existing test suite (695 passing) plus typecheck and lint. --- .../src/api-data/rundown/rundown.dao.ts | 5 ++- .../src/api-data/rundown/rundown.parser.ts | 4 +- .../src/api-data/rundown/rundown.service.ts | 7 ++-- .../src/api-data/rundown/rundown.utils.ts | 42 ++++++++++++++++++- .../src/api-data/sheets/sheets.service.ts | 5 +-- .../src/classes/data-provider/DataProvider.ts | 3 +- .../data-provider/DataProvider.utils.ts | 13 ++++-- .../runtime-service/runtime.service.ts | 3 +- 8 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index 460031a9d..ef42edafe 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -34,6 +34,7 @@ import { ProcessedRundownMetadata, makeRundownMetadata } from './rundown.parser. import type { RundownMetadata } from './rundown.types.js'; import { applyPatchToEntry, + cloneRundown, cloneSimpleRundownEntry, deleteById, doesInvalidateMetadata, @@ -106,7 +107,7 @@ export function createTransaction(options: TransactionOptions): Transaction { const targetId = options.rundownId ?? cachedRundown.id; const isLoaded = targetId === cachedRundown.id; const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown); - const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown; + const rundown = options.mutableRundown ? cloneRundown(sourceRundown) : sourceRundown; const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields; /** @@ -707,7 +708,7 @@ export const customFieldMutation = { * Expose function to add an initial rundown to the system */ export function init(initialRundown: Readonly, initialCustomFields: Readonly) { - const rundown = structuredClone(initialRundown); + const rundown = cloneRundown(initialRundown); const customFields = structuredClone(initialCustomFields); const processedData = processRundown(rundown, customFields, { mutate: true }); diff --git a/apps/server/src/api-data/rundown/rundown.parser.ts b/apps/server/src/api-data/rundown/rundown.parser.ts index e269490e9..14c1721aa 100644 --- a/apps/server/src/api-data/rundown/rundown.parser.ts +++ b/apps/server/src/api-data/rundown/rundown.parser.ts @@ -33,7 +33,7 @@ import { import { makeNewRundown } from '../../models/dataModel.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js'; import { RundownMetadata } from './rundown.types.js'; -import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js'; +import { calculateDayOffset, cleanupCustomFields, cloneEntryData } from './rundown.utils.js'; /** * Parse a rundowns object along with the project custom fields @@ -234,7 +234,7 @@ export function makeRundownMetadata(customFields: CustomFields, options?: { muta }; function process(entry: T, childOfGroup: EntryId | null): T { - return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup); + return processEntry(rundownMeta, customFields, mutate ? entry : cloneEntryData(entry), childOfGroup); } function getMetadata(): ProcessedRundownMetadata { diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index b4ceb8f16..4a91cf2bd 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -39,6 +39,7 @@ import { import { parseRundown } from './rundown.parser.js'; import type { RundownMetadata } from './rundown.types.js'; import { + cloneRundown, generateEvent, getFirstInsertId, getIntegerAndFraction, @@ -626,7 +627,7 @@ export async function editCustomField( // ... reassign references in the background rundowns for (const rundownId of Object.keys(projectRundowns)) { if (rundownId !== rundown.id) { - const backgroundRundown = structuredClone(projectRundowns[rundownId]); + const backgroundRundown = cloneRundown(projectRundowns[rundownId]); customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey); await updateBackgroundRundown(rundownId, backgroundRundown); } @@ -666,7 +667,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr // remove references in the background rundowns for (const rundownId of Object.keys(projectRundowns)) { if (rundownId !== rundown.id) { - const backgroundRundown = structuredClone(projectRundowns[rundownId]); + const backgroundRundown = cloneRundown(projectRundowns[rundownId]); customFieldMutation.removeUsages(backgroundRundown, key); await updateBackgroundRundown(rundownId, backgroundRundown); } @@ -846,7 +847,7 @@ export async function duplicateExistingRundown(id: string) { const dataProvider = getDataProvider(); const rundown = dataProvider.getRundown(id); - const duplicatedRundown: Rundown = structuredClone(rundown); + const duplicatedRundown: Rundown = cloneRundown(rundown); duplicatedRundown.id = generateId(); duplicatedRundown.title = `Copy of ${rundown.title}`; duplicatedRundown.revision = 0; diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index d1d0407db..b22e0309b 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -329,7 +329,7 @@ export function mergeRundownPreservingFields( const structure = isOntimeGroup(incomingEntry) ? { entries: incomingEntry.entries } : { parent: incomingEntry.parent }; - entries[id] = structuredClone({ ...merged, ...structure }); + entries[id] = cloneEntryData({ ...merged, ...structure }); } return { @@ -499,6 +499,46 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont throw new Error(`Unsupported entry type for cloning: ${entry}`); } +/** + * Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc). + * Drop-in replacement for `structuredClone(entry)`: the generic structured-clone + * algorithm does far more work than the plain object spreads an entry this shallow needs. + */ +export function cloneEntryData(entry: T): T { + switch (entry.type) { + case SupportedEntry.Event: + return { ...entry, custom: { ...entry.custom }, triggers: entry.triggers?.slice() ?? [] } as T; + case SupportedEntry.Group: + return { ...entry, custom: { ...entry.custom }, entries: entry.entries?.slice() ?? [] } as T; + case SupportedEntry.Milestone: + return { ...entry, custom: { ...entry.custom } } as T; + case SupportedEntry.Delay: + return { ...entry } as T; + } +} + +/** + * Fast, shape-aware clone of a whole rundown. + * Drop-in replacement for `structuredClone(rundown)`: every entry (and its nested + * `custom` / `triggers` / `entries` containers) gets its own copy, so callers can mutate + * the result freely without touching the source - same contract as structuredClone, + * at a fraction of the cost since we skip the generic serialization algorithm. + */ +export function cloneRundown(rundown: Readonly): Rundown { + const entries: RundownEntries = {}; + for (const id in rundown.entries) { + entries[id] = cloneEntryData(rundown.entries[id]); + } + return { + id: rundown.id, + title: rundown.title, + revision: rundown.revision, + order: rundown.order.slice(), + flatOrder: rundown.flatOrder.slice(), + entries, + }; +} + /** * Utility for calculating if the current events should have a day offset * @param current the current event under test diff --git a/apps/server/src/api-data/sheets/sheets.service.ts b/apps/server/src/api-data/sheets/sheets.service.ts index e6d7a68de..2cec76b55 100644 --- a/apps/server/src/api-data/sheets/sheets.service.ts +++ b/apps/server/src/api-data/sheets/sheets.service.ts @@ -464,9 +464,8 @@ export async function upload(sheetId: string, options: ImportMap) { sheetOrder.forEach((entryId, index) => { const isGroupEnd = entryId.startsWith('group-end-'); const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId; - const entry = isGroupEnd - ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) - : structuredClone(rundown.entries[id]); + // cellRequestFromEvent only reads the entry to build a cell request, no clone is needed + const entry = isGroupEnd ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) : rundown.entries[id]; updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata)); }); } catch (e) { diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 44cc2aff6..35d781d64 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -12,6 +12,7 @@ import { ViewSettings, } from 'ontime-types'; +import { cloneRundown } from '../../api-data/rundown/rundown.utils.js'; import { isTest } from '../../setup/environment.js'; import { shouldCrashDev } from '../../utils/development.js'; import { isPath } from '../../utils/fileManagement.js'; @@ -102,7 +103,7 @@ function getCustomFields(): Readonly { } async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise { - db.data.rundowns[rundownKey] = structuredClone(newData); + db.data.rundowns[rundownKey] = cloneRundown(newData); await persist(); return db.data.rundowns; } diff --git a/apps/server/src/classes/data-provider/DataProvider.utils.ts b/apps/server/src/classes/data-provider/DataProvider.utils.ts index 6a2fbb756..c57bec8b8 100644 --- a/apps/server/src/classes/data-provider/DataProvider.utils.ts +++ b/apps/server/src/classes/data-provider/DataProvider.utils.ts @@ -4,12 +4,17 @@ import { DatabaseModel } from 'ontime-types'; * Merges a partial ontime project into a given ontime project */ export function safeMerge(existing: DatabaseModel, newData: Partial): DatabaseModel { - const deepExisting = structuredClone(existing); - const deepNewData = structuredClone(newData); + // rundowns are merged separately below by reference (only the top-level map is copied, + // same as the other properties here) - deep-cloning them here would be wasted work, + // since a project's rundowns are by far the largest part of this object + const { rundowns: existingRundowns, ...existingRest } = existing; + const { rundowns: newRundowns = {}, ...newDataRest } = newData; + + const deepExisting = structuredClone(existingRest); + const deepNewData = structuredClone(newDataRest); // destructure each property to simplify merging not provided ie: ...{} has no effect const { - rundowns = {}, project = {}, settings = {}, viewSettings = {}, @@ -19,7 +24,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial