Skip to content

Commit 9eaf990

Browse files
perf(vscode): shard parser cache storage to fix large-workspace lag
The parser cache was persisted under a single workspaceState key, so every debounced sync serialized the entire cache (up to 10k entries, ~50MB) and shipped it over IPC to the main thread, freezing the UI on large workspaces during startup and while typing. The cache now persists to 64 bucket files under the extension storageUri. A note maps to a stable bucket via a hash of its URI and only dirty buckets are rewritten, so persistence cost is proportional to the change, not the workspace size. The legacy workspaceState blob is migrated to the new layout on first activation and removed. Pending writes are flushed on shutdown, and clear() can no longer be undone by an in-flight sync. Supersedes #1677. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e6e73a8 commit 9eaf990

4 files changed

Lines changed: 449 additions & 34 deletions

File tree

packages/foam-vscode/src/extension.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export async function activate(context: ExtensionContext) {
6868
workspace.createFileSystemWatcher('**/*'),
6969
workspace.onDidSaveTextDocument
7070
);
71-
const parserCache = new VsCodeBasedParserCache(context);
71+
const parserCache = await VsCodeBasedParserCache.create(context);
7272
const parser = createMarkdownParser([], parserCache);
7373

7474
const notesExtensions = Config.getNotesExtensions();
@@ -132,6 +132,7 @@ export async function activate(context: ExtensionContext) {
132132
context.subscriptions.push(
133133
foam,
134134
watcher,
135+
parserCache,
135136
markdownProvider,
136137
attachmentProvider,
137138
commands.registerCommand('foam-vscode.clear-cache', () => parserCache.clear()),
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/* @unit-ready */
2+
import * as fs from 'fs';
3+
import * as os from 'os';
4+
import * as path from 'path';
5+
import { ExtensionContext, Uri } from 'vscode';
6+
import { URI, ParserCacheEntry } from '@foam/core';
7+
import { createTestNote, randomString } from '../../test/test-utils';
8+
import { MapBasedMemento } from '../utils/vsc-utils';
9+
import VsCodeBasedParserCache from './cache';
10+
11+
describe('VsCodeBasedParserCache', () => {
12+
const createContext = (storageDir?: string): ExtensionContext => {
13+
return {
14+
workspaceState: new MapBasedMemento(),
15+
storageUri: storageDir ? Uri.file(storageDir) : undefined,
16+
} as unknown as ExtensionContext;
17+
};
18+
19+
const createTmpStorageDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'foam-cache-spec-'));
20+
21+
const createEntry = (name: string): { uri: URI; entry: ParserCacheEntry } => {
22+
const resource = createTestNote({ uri: `/notes/${name}.md` });
23+
return {
24+
uri: resource.uri,
25+
entry: { checksum: randomString(), resource },
26+
};
27+
};
28+
29+
const bucketDirOf = (storageDir: string) =>
30+
path.join(storageDir, VsCodeBasedParserCache.CACHE_DIR_NAME);
31+
32+
const snapshotBucketFiles = (storageDir: string): Map<string, string> => {
33+
const dir = bucketDirOf(storageDir);
34+
const snapshot = new Map<string, string>();
35+
for (const file of fs.readdirSync(dir)) {
36+
snapshot.set(file, fs.readFileSync(path.join(dir, file), 'utf8'));
37+
}
38+
return snapshot;
39+
};
40+
41+
it('starts empty when no persisted cache exists', async () => {
42+
const storageDir = createTmpStorageDir();
43+
const cache = await VsCodeBasedParserCache.create(createContext(storageDir));
44+
const { uri } = createEntry('some-note');
45+
expect(cache.has(uri)).toBe(false);
46+
expect(cache.get(uri)).toBeUndefined();
47+
});
48+
49+
it('returns entries with a rehydrated URI instance', async () => {
50+
const storageDir = createTmpStorageDir();
51+
const cache = await VsCodeBasedParserCache.create(createContext(storageDir));
52+
const { uri, entry } = createEntry('a-note');
53+
cache.set(uri, entry);
54+
const result = cache.get(uri);
55+
expect(result.checksum).toEqual(entry.checksum);
56+
expect(result.resource.uri).toBeInstanceOf(URI);
57+
expect(result.resource.uri.isEqual(uri)).toBe(true);
58+
});
59+
60+
it('persists entries across instances', async () => {
61+
const storageDir = createTmpStorageDir();
62+
const context = createContext(storageDir);
63+
64+
const cache = await VsCodeBasedParserCache.create(context);
65+
const { uri, entry } = createEntry('a-note');
66+
cache.set(uri, entry);
67+
await cache.flush();
68+
69+
const reloaded = await VsCodeBasedParserCache.create(context);
70+
expect(reloaded.has(uri)).toBe(true);
71+
expect(reloaded.get(uri).checksum).toEqual(entry.checksum);
72+
expect(reloaded.get(uri).resource.title).toEqual(entry.resource.title);
73+
});
74+
75+
it('removes deleted entries from the persisted cache', async () => {
76+
const storageDir = createTmpStorageDir();
77+
const context = createContext(storageDir);
78+
79+
const cache = await VsCodeBasedParserCache.create(context);
80+
const first = createEntry('first');
81+
const second = createEntry('second');
82+
cache.set(first.uri, first.entry);
83+
cache.set(second.uri, second.entry);
84+
await cache.flush();
85+
86+
cache.del(first.uri);
87+
await cache.flush();
88+
89+
const reloaded = await VsCodeBasedParserCache.create(context);
90+
expect(reloaded.has(first.uri)).toBe(false);
91+
expect(reloaded.has(second.uri)).toBe(true);
92+
});
93+
94+
it('only rewrites the bucket files affected by a change', async () => {
95+
const storageDir = createTmpStorageDir();
96+
const context = createContext(storageDir);
97+
98+
const cache = await VsCodeBasedParserCache.create(context);
99+
const notes = [...Array(50).keys()].map(i => createEntry(`note-${i}`));
100+
for (const { uri, entry } of notes) {
101+
cache.set(uri, entry);
102+
}
103+
await cache.flush();
104+
105+
const before = snapshotBucketFiles(storageDir);
106+
// 50 keys over 64 buckets: more than one bucket file must exist,
107+
// otherwise this test would pass vacuously
108+
expect(before.size).toBeGreaterThan(1);
109+
110+
cache.set(notes[0].uri, {
111+
...notes[0].entry,
112+
checksum: 'updated-checksum',
113+
});
114+
await cache.flush();
115+
116+
const after = snapshotBucketFiles(storageDir);
117+
const changed = [...after.keys()].filter(file => before.get(file) !== after.get(file));
118+
expect(changed.length).toEqual(1);
119+
});
120+
121+
it('clears both the in-memory and the persisted cache', async () => {
122+
const storageDir = createTmpStorageDir();
123+
const context = createContext(storageDir);
124+
125+
const cache = await VsCodeBasedParserCache.create(context);
126+
const { uri, entry } = createEntry('a-note');
127+
cache.set(uri, entry);
128+
await cache.flush();
129+
130+
await cache.clear();
131+
expect(cache.has(uri)).toBe(false);
132+
expect(fs.existsSync(bucketDirOf(storageDir))).toBe(false);
133+
134+
const reloaded = await VsCodeBasedParserCache.create(context);
135+
expect(reloaded.has(uri)).toBe(false);
136+
});
137+
138+
it('is not resurrected by a pending sync scheduled before clear()', async () => {
139+
const storageDir = createTmpStorageDir();
140+
const context = createContext(storageDir);
141+
142+
const cache = await VsCodeBasedParserCache.create(context);
143+
const { uri, entry } = createEntry('a-note');
144+
// set() schedules a debounced sync; clear() before it fires must win
145+
cache.set(uri, entry);
146+
await cache.clear();
147+
await cache.flush();
148+
149+
const reloaded = await VsCodeBasedParserCache.create(context);
150+
expect(reloaded.has(uri)).toBe(false);
151+
expect(fs.existsSync(bucketDirOf(storageDir))).toBe(false);
152+
});
153+
154+
it('recovers from a corrupted bucket file, keeping the other buckets', async () => {
155+
const storageDir = createTmpStorageDir();
156+
const context = createContext(storageDir);
157+
158+
const cache = await VsCodeBasedParserCache.create(context);
159+
const notes = [...Array(50).keys()].map(i => createEntry(`note-${i}`));
160+
for (const { uri, entry } of notes) {
161+
cache.set(uri, entry);
162+
}
163+
await cache.flush();
164+
165+
const bucketFiles = fs.readdirSync(bucketDirOf(storageDir));
166+
expect(bucketFiles.length).toBeGreaterThan(1);
167+
const corrupted = path.join(bucketDirOf(storageDir), bucketFiles[0]);
168+
fs.writeFileSync(corrupted, 'not valid json {');
169+
170+
const reloaded = await VsCodeBasedParserCache.create(context);
171+
const loadedCount = notes.filter(({ uri }) => reloaded.has(uri)).length;
172+
expect(loadedCount).toBeGreaterThan(0);
173+
expect(loadedCount).toBeLessThan(notes.length);
174+
// the corrupted bucket is discarded so it doesn't fail every startup
175+
expect(fs.existsSync(corrupted)).toBe(false);
176+
});
177+
178+
it('migrates the legacy workspaceState cache and removes the old key', async () => {
179+
const storageDir = createTmpStorageDir();
180+
const context = createContext(storageDir);
181+
const { uri, entry } = createEntry('legacy-note');
182+
await context.workspaceState.update(
183+
VsCodeBasedParserCache.CACHE_VERSION_KEY,
184+
VsCodeBasedParserCache.CACHE_VERSION
185+
);
186+
await context.workspaceState.update(VsCodeBasedParserCache.LEGACY_STATE_KEY, [
187+
[uri.toString(), { value: entry }],
188+
]);
189+
190+
const cache = await VsCodeBasedParserCache.create(context);
191+
expect(cache.has(uri)).toBe(true);
192+
expect(cache.get(uri).checksum).toEqual(entry.checksum);
193+
expect(context.workspaceState.get(VsCodeBasedParserCache.LEGACY_STATE_KEY)).toBeUndefined();
194+
195+
// the migrated entries are re-persisted in the sharded layout
196+
await cache.flush();
197+
const reloaded = await VsCodeBasedParserCache.create(context);
198+
expect(reloaded.has(uri)).toBe(true);
199+
});
200+
201+
it('discards the persisted cache when the version changes', async () => {
202+
const storageDir = createTmpStorageDir();
203+
const context = createContext(storageDir);
204+
205+
const cache = await VsCodeBasedParserCache.create(context);
206+
const { uri, entry } = createEntry('a-note');
207+
cache.set(uri, entry);
208+
await cache.flush();
209+
210+
await context.workspaceState.update(
211+
VsCodeBasedParserCache.CACHE_VERSION_KEY,
212+
VsCodeBasedParserCache.CACHE_VERSION - 1
213+
);
214+
215+
const reloaded = await VsCodeBasedParserCache.create(context);
216+
expect(reloaded.has(uri)).toBe(false);
217+
expect(context.workspaceState.get(VsCodeBasedParserCache.CACHE_VERSION_KEY)).toEqual(
218+
VsCodeBasedParserCache.CACHE_VERSION
219+
);
220+
});
221+
222+
it('operates in-memory when no storage is available', async () => {
223+
const cache = await VsCodeBasedParserCache.create(createContext());
224+
const { uri, entry } = createEntry('a-note');
225+
cache.set(uri, entry);
226+
expect(cache.has(uri)).toBe(true);
227+
await cache.flush();
228+
cache.del(uri);
229+
expect(cache.has(uri)).toBe(false);
230+
await cache.clear();
231+
});
232+
});

0 commit comments

Comments
 (0)