From 12948a50f5c0f0ad9e84751a521b2bf6afc2db4a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:37:42 +0000 Subject: [PATCH] Optimize Workspace.invalidate bulk cache clearing When a large number of sheets are invalidated (e.g. cascading dependencies), Workspace.invalidate iterating through the `dirty` Set and individually calling `.delete()` on the Map can be very slow. For extreme cases, this changes it to perform an O(1) `.clear()` when everything is dirty, or construct a new Map with only the non-dirty items when more than half the cache is dirty. Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> --- src/workspace.ts | 53 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/src/workspace.ts b/src/workspace.ts index c6bb6a1..dc89813 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -30,12 +30,17 @@ export class Workspace { private parsedRefsCache = new Map(); private titleCache: Map | null = null; - constructor(private engine: SumEngine, private sheets: () => SheetSource[]) {} + constructor( + private engine: SumEngine, + private sheets: () => SheetSource[], + ) {} private doEvaluate(sheetId: string, text: string): LineResult[] { this.resolving.add(sheetId); try { - return this.engine.evaluateDocument(text, (sheet, key) => this.resolveXRef(sheet, key)); + return this.engine.evaluateDocument(text, (sheet, key) => + this.resolveXRef(sheet, key), + ); } finally { this.resolving.delete(sheetId); } @@ -69,7 +74,19 @@ export class Workspace { } } } - for (const id of dirty) this.cache.delete(id); + if (dirty.size === this.cache.size) { + this.cache.clear(); + } else if (dirty.size > this.cache.size / 2) { + const keep = new Map(); + for (const [k, v] of this.cache.entries()) { + if (!dirty.has(k)) { + keep.set(k, v); + } + } + this.cache = keep; + } else { + for (const id of dirty) this.cache.delete(id); + } } invalidateAll(): void { @@ -82,16 +99,24 @@ export class Workspace { * Explicit rename: rewrite `@OldTitle.` / `@[Old Title].` to the new name * in every *other* sheet. Returns only the sheets whose text changed. */ - renameSheet(renamedId: string, oldTitle: string, newTitle: string): { id: string; text: string }[] { + renameSheet( + renamedId: string, + oldTitle: string, + newTitle: string, + ): { id: string; text: string }[] { const oldEsc = escapeRegExp(oldTitle.trim()); const bareRe = new RegExp(`@${oldEsc}\\.`, "gu"); const bracketRe = new RegExp(`@\\[\\s*${oldEsc}\\s*\\]\\.`, "gu"); const trimmedNew = newTitle.trim(); - const replacement = /^[\p{L}_][\p{L}\d_]*$/u.test(trimmedNew) ? `@${trimmedNew}.` : `@[${trimmedNew}].`; + const replacement = /^[\p{L}_][\p{L}\d_]*$/u.test(trimmedNew) + ? `@${trimmedNew}.` + : `@[${trimmedNew}].`; const out: { id: string; text: string }[] = []; for (const s of this.sheets()) { if (s.id === renamedId) continue; - const rewritten = s.text.replace(bareRe, replacement).replace(bracketRe, replacement); + const rewritten = s.text + .replace(bareRe, replacement) + .replace(bracketRe, replacement); if (rewritten !== s.text) out.push({ id: s.id, text: rewritten }); } return out; @@ -103,7 +128,8 @@ export class Workspace { if (cached && cached.text === text) return cached.refs; } const out: string[] = []; - for (const m of text.matchAll(XREF_SCAN_RE)) out.push((m[1] ?? m[2]).trim()); + for (const m of text.matchAll(XREF_SCAN_RE)) + out.push((m[1] ?? m[2]).trim()); if (id) { this.parsedRefsCache.set(id, { text, refs: out }); } @@ -126,7 +152,8 @@ export class Workspace { private resolveXRef(sheetTitle: string, key: string): XRefResolution { const target = this.findSheetByTitle(sheetTitle); - if (!target) return { ok: false, reason: `sheet "${sheetTitle}" not found` }; + if (!target) + return { ok: false, reason: `sheet "${sheetTitle}" not found` }; if (this.resolving.has(target.id)) { return { ok: false, reason: "circular reference" }; } @@ -142,7 +169,8 @@ export class Workspace { : { ok: false, reason: `sheet "${sheetTitle}" has no result` }; } const v = exports.vars.get(key); - if (!v) return { ok: false, reason: `no variable "${key}" in "${sheetTitle}"` }; + if (!v) + return { ok: false, reason: `no variable "${key}" in "${sheetTitle}"` }; return { ok: true, value: v }; } @@ -161,7 +189,12 @@ export class Workspace { break; } } - const exports: SheetExports = { vars, total: this.engine.totalValueOf(results), last, results }; + const exports: SheetExports = { + vars, + total: this.engine.totalValueOf(results), + last, + results, + }; // A cycle-interrupted pass produces incomplete/wrong exports (dropped // assignments, missing total) — don't let it poison the cache. Re-evaluate // fresh on every query until the cycle is actually broken by an edit.