perf: optimize document invalidation using inverse dependency graph

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-08-26 14:04:06 +00:00
parent 37cfd2144b
commit f13a3ac665

View file

@ -30,12 +30,17 @@ export class Workspace {
private parsedRefsCache = new Map<string, { text: string; refs: string[] }>(); private parsedRefsCache = new Map<string, { text: string; refs: string[] }>();
private titleCache: Map<string, SheetSource> | null = null; private titleCache: Map<string, SheetSource> | null = null;
constructor(private engine: SumEngine, private sheets: () => SheetSource[]) {} constructor(
private engine: SumEngine,
private sheets: () => SheetSource[],
) {}
private doEvaluate(sheetId: string, text: string): LineResult[] { private doEvaluate(sheetId: string, text: string): LineResult[] {
this.resolving.add(sheetId); this.resolving.add(sheetId);
try { try {
return this.engine.evaluateDocument(text, (sheet, key) => this.resolveXRef(sheet, key)); return this.engine.evaluateDocument(text, (sheet, key) =>
this.resolveXRef(sheet, key),
);
} finally { } finally {
this.resolving.delete(sheetId); this.resolving.delete(sheetId);
} }
@ -55,21 +60,36 @@ export class Workspace {
this.titleCache = null; this.titleCache = null;
const dirty = new Set<string>([sheetId]); const dirty = new Set<string>([sheetId]);
const sheetsList = this.sheets(); const sheetsList = this.sheets();
let changed = true;
while (changed) { const dependents = new Map<string, string[]>();
changed = false; for (const s of sheetsList) {
for (const s of sheetsList) { for (const title of this.referencedTitles(s.text, s.id)) {
if (dirty.has(s.id)) continue; const target = this.findSheetByTitle(title);
for (const title of this.referencedTitles(s.text, s.id)) { if (target) {
const target = this.findSheetByTitle(title); let deps = dependents.get(target.id);
if (target && dirty.has(target.id)) { if (!deps) {
dirty.add(s.id); deps = [];
changed = true; dependents.set(target.id, deps);
break; }
deps.push(s.id);
}
}
}
const queue = [sheetId];
for (let i = 0; i < queue.length; i++) {
const current = queue[i];
const deps = dependents.get(current);
if (deps) {
for (const dep of deps) {
if (!dirty.has(dep)) {
dirty.add(dep);
queue.push(dep);
} }
} }
} }
} }
for (const id of dirty) this.cache.delete(id); for (const id of dirty) this.cache.delete(id);
} }
@ -83,16 +103,24 @@ export class Workspace {
* Explicit rename: rewrite `@OldTitle.` / `@[Old Title].` to the new name * Explicit rename: rewrite `@OldTitle.` / `@[Old Title].` to the new name
* in every *other* sheet. Returns only the sheets whose text changed. * 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 oldEsc = escapeRegExp(oldTitle.trim());
const bareRe = new RegExp(`@${oldEsc}\\.`, "gu"); const bareRe = new RegExp(`@${oldEsc}\\.`, "gu");
const bracketRe = new RegExp(`@\\[\\s*${oldEsc}\\s*\\]\\.`, "gu"); const bracketRe = new RegExp(`@\\[\\s*${oldEsc}\\s*\\]\\.`, "gu");
const trimmedNew = newTitle.trim(); 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 }[] = []; const out: { id: string; text: string }[] = [];
for (const s of this.sheets()) { for (const s of this.sheets()) {
if (s.id === renamedId) continue; 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 }); if (rewritten !== s.text) out.push({ id: s.id, text: rewritten });
} }
return out; return out;
@ -104,7 +132,8 @@ export class Workspace {
if (cached && cached.text === text) return cached.refs; if (cached && cached.text === text) return cached.refs;
} }
const out: string[] = []; 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) { if (id) {
this.parsedRefsCache.set(id, { text, refs: out }); this.parsedRefsCache.set(id, { text, refs: out });
} }
@ -127,7 +156,8 @@ export class Workspace {
private resolveXRef(sheetTitle: string, key: string): XRefResolution { private resolveXRef(sheetTitle: string, key: string): XRefResolution {
const target = this.findSheetByTitle(sheetTitle); 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)) { if (this.resolving.has(target.id)) {
return { ok: false, reason: "circular reference" }; return { ok: false, reason: "circular reference" };
} }
@ -143,7 +173,8 @@ export class Workspace {
: { ok: false, reason: `sheet "${sheetTitle}" has no result` }; : { ok: false, reason: `sheet "${sheetTitle}" has no result` };
} }
const v = exports.vars.get(key); 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 }; return { ok: true, value: v };
} }
@ -162,7 +193,12 @@ export class Workspace {
break; 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 // A cycle-interrupted pass produces incomplete/wrong exports (dropped
// assignments, missing total) — don't let it poison the cache. Re-evaluate // 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. // fresh on every query until the cycle is actually broken by an edit.