Skip to content

Commit f4e060e

Browse files
authored
feat: add LRU eviction with entry limit to SchemaStore to prevent unbounded memory growth (fixes #604) (#724)
2 parents d37fe55 + 76e532d commit f4e060e

2 files changed

Lines changed: 173 additions & 7 deletions

File tree

src/documentdb/SchemaStore.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { type IActionContext } from '@microsoft/vscode-azext-utils';
67
import { ObjectId } from 'bson';
78
import { SchemaStore, type SchemaChangeEvent } from './SchemaStore';
89

10+
// Replace the telemetry helper with a no-op mock; individual tests override the
11+
// implementation to capture the measurements reported by `schemaStore.stats`.
12+
jest.mock('@microsoft/vscode-azext-utils', () => ({
13+
callWithTelemetryAndErrorHandling: jest.fn(),
14+
}));
15+
916
describe('SchemaStore', () => {
1017
let store: SchemaStore;
1118

@@ -180,6 +187,85 @@ describe('SchemaStore', () => {
180187
expect(after).not.toBe(before);
181188
});
182189

190+
// ── LRU eviction (memory ceiling, issue #604) ──
191+
192+
describe('LRU eviction', () => {
193+
// Mirrors SchemaStore.DEFAULT_MAX_ENTRIES.
194+
const MAX_ENTRIES = 50;
195+
196+
function fillCollections(count: number, prefix = 'coll'): void {
197+
for (let i = 0; i < count; i++) {
198+
store.addDocuments(clusterId, db, `${prefix}-${i}`, makeDocs([{ idx: i }]));
199+
}
200+
}
201+
202+
it('caps the number of cached collections at the limit', () => {
203+
fillCollections(MAX_ENTRIES + 25);
204+
expect(store.getStats().collectionCount).toBe(MAX_ENTRIES);
205+
});
206+
207+
it('evicts the least-recently-used collection when exceeding the cap', () => {
208+
fillCollections(MAX_ENTRIES); // coll-0 (LRU) ... coll-49 (MRU)
209+
210+
// One more insertion pushes the cache over the cap and evicts coll-0.
211+
store.addDocuments(clusterId, db, 'coll-new', makeDocs([{ x: 1 }]));
212+
213+
expect(store.hasSchema(clusterId, db, 'coll-0')).toBe(false);
214+
expect(store.hasSchema(clusterId, db, 'coll-new')).toBe(true);
215+
expect(store.getStats().collectionCount).toBe(MAX_ENTRIES);
216+
});
217+
218+
it('treats a read as recent use, protecting recently-read collections', () => {
219+
fillCollections(MAX_ENTRIES); // coll-0 (LRU) ... coll-49 (MRU)
220+
221+
// Reading coll-0 makes it most-recently-used, so coll-1 becomes the LRU.
222+
expect(store.hasSchema(clusterId, db, 'coll-0')).toBe(true);
223+
224+
store.addDocuments(clusterId, db, 'coll-new', makeDocs([{ x: 1 }]));
225+
226+
// coll-1 is evicted; coll-0 survives because it was just read.
227+
expect(store.hasSchema(clusterId, db, 'coll-1')).toBe(false);
228+
expect(store.hasSchema(clusterId, db, 'coll-0')).toBe(true);
229+
});
230+
231+
it('allows an evicted collection to be re-added', () => {
232+
fillCollections(MAX_ENTRIES);
233+
store.addDocuments(clusterId, db, 'coll-new', makeDocs([{ x: 1 }])); // evicts coll-0
234+
expect(store.hasSchema(clusterId, db, 'coll-0')).toBe(false);
235+
236+
store.addDocuments(clusterId, db, 'coll-0', makeDocs([{ y: 2 }]));
237+
238+
expect(store.hasSchema(clusterId, db, 'coll-0')).toBe(true);
239+
expect(store.getStats().collectionCount).toBe(MAX_ENTRIES);
240+
});
241+
242+
it('reports cache size, cap, and eviction count via telemetry', async () => {
243+
const measurements: Record<string, number> = {};
244+
const callWithTelemetryMock = jest.requireMock('@microsoft/vscode-azext-utils')
245+
.callWithTelemetryAndErrorHandling as jest.Mock;
246+
callWithTelemetryMock.mockImplementation(
247+
async (_callbackId: string, callback: (ctx: IActionContext) => unknown) => {
248+
const ctx = {
249+
telemetry: { properties: {}, measurements },
250+
errorHandling: {},
251+
} as unknown as IActionContext;
252+
await callback(ctx);
253+
return undefined;
254+
},
255+
);
256+
257+
try {
258+
fillCollections(MAX_ENTRIES + 10); // 10 evictions
259+
} finally {
260+
callWithTelemetryMock.mockReset();
261+
}
262+
263+
expect(measurements.maxEntries).toBe(MAX_ENTRIES);
264+
expect(measurements.currentCollectionCount).toBe(MAX_ENTRIES);
265+
expect(measurements.evictionCount).toBeGreaterThanOrEqual(10);
266+
});
267+
});
268+
183269
// ── Events (debounced) ──
184270

185271
describe('onDidChangeSchema', () => {

src/documentdb/SchemaStore.ts

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,21 @@ export interface SchemaStoreStats {
4646
*
4747
* Schema change notifications are debounced per key (1 second) to avoid
4848
* excessive churn when pages are navigated rapidly.
49+
*
50+
* The cache enforces a soft entry limit (default 50 collections) with LRU
51+
* eviction to prevent unbounded memory growth in long-running sessions.
4952
*/
5053
export class SchemaStore implements vscode.Disposable {
54+
private static readonly DEFAULT_MAX_ENTRIES = 50;
55+
5156
private static _instance: SchemaStore | undefined;
5257
private readonly _analyzers = new Map<string, SchemaAnalyzer>();
5358
private readonly _onDidChangeSchema = new vscode.EventEmitter<SchemaChangeEvent>();
5459
private readonly _pendingNotifications = new Map<string, ReturnType<typeof setTimeout>>();
60+
private readonly _accessOrder = new Map<string, number>();
61+
62+
private _maxEntries: number = SchemaStore.DEFAULT_MAX_ENTRIES;
63+
private _evictionCount = 0;
5564

5665
/** High-water marks for telemetry — tracks peak usage across the session. */
5766
private _maxCollectionCount = 0;
@@ -77,29 +86,90 @@ export class SchemaStore implements vscode.Disposable {
7786
return `${clusterId}::${db}::${coll}`;
7887
}
7988

89+
// ── LRU tracking ──
90+
91+
/** Mark a key as recently accessed (moves to end of insertion order). */
92+
private _touchKey(key: string): void {
93+
this._accessOrder.delete(key);
94+
this._accessOrder.set(key, 0);
95+
}
96+
97+
/** Evict least-recently-used entries until the store is at or below capacity. */
98+
private _evictIfNeeded(): void {
99+
while (this._analyzers.size > this._maxEntries) {
100+
const oldestKey = this._accessOrder.keys().next().value as string | undefined;
101+
if (oldestKey !== undefined) {
102+
this._analyzers.delete(oldestKey);
103+
this._accessOrder.delete(oldestKey);
104+
this._evictionCount++;
105+
// Flag a stats change so the eviction is reported to telemetry
106+
// promptly (eviction lowers the cache size, so the high-water
107+
// mark check in _updateMaxStats would otherwise not flush it).
108+
this._statsChanged = true;
109+
const pending = this._pendingNotifications.get(oldestKey);
110+
if (pending !== undefined) {
111+
clearTimeout(pending);
112+
this._pendingNotifications.delete(oldestKey);
113+
}
114+
continue;
115+
}
116+
117+
// Fallback: _accessOrder may be out of sync with _analyzers.
118+
// Pick any entry from _analyzers and register it for future LRU.
119+
const fallbackKey = this._analyzers.keys().next().value as string | undefined;
120+
if (fallbackKey !== undefined) {
121+
this._touchKey(fallbackKey);
122+
continue;
123+
}
124+
125+
return;
126+
}
127+
}
128+
80129
// ── Read operations ──
81130

82131
/** Check if schema data exists for a collection. */
83132
public hasSchema(clusterId: string, db: string, coll: string): boolean {
84-
const analyzer = this._analyzers.get(SchemaStore.key(clusterId, db, coll));
85-
return analyzer !== undefined && analyzer.getDocumentCount() > 0;
133+
const key = SchemaStore.key(clusterId, db, coll);
134+
const analyzer = this._analyzers.get(key);
135+
if (analyzer && analyzer.getDocumentCount() > 0) {
136+
this._touchKey(key);
137+
return true;
138+
}
139+
return false;
86140
}
87141

88142
/** Get known fields for a collection (empty array if no schema). */
89143
public getKnownFields(clusterId: string, db: string, coll: string): FieldEntry[] {
90-
const analyzer = this._analyzers.get(SchemaStore.key(clusterId, db, coll));
91-
return analyzer?.getKnownFields() ?? [];
144+
const key = SchemaStore.key(clusterId, db, coll);
145+
const analyzer = this._analyzers.get(key);
146+
if (analyzer) {
147+
this._touchKey(key);
148+
return analyzer.getKnownFields();
149+
}
150+
return [];
92151
}
93152

94153
/** Get the raw JSON Schema for a collection (empty schema if none). */
95154
public getSchema(clusterId: string, db: string, coll: string): JSONSchema {
96-
const analyzer = this._analyzers.get(SchemaStore.key(clusterId, db, coll));
97-
return analyzer?.getSchema() ?? { type: 'object' };
155+
const key = SchemaStore.key(clusterId, db, coll);
156+
const analyzer = this._analyzers.get(key);
157+
if (analyzer) {
158+
this._touchKey(key);
159+
return analyzer.getSchema();
160+
}
161+
return { type: 'object' };
98162
}
99163

100164
/** Get schema document count for a collection. */
101165
public getDocumentCount(clusterId: string, db: string, coll: string): number {
102-
return this._analyzers.get(SchemaStore.key(clusterId, db, coll))?.getDocumentCount() ?? 0;
166+
const key = SchemaStore.key(clusterId, db, coll);
167+
const analyzer = this._analyzers.get(key);
168+
if (analyzer) {
169+
this._touchKey(key);
170+
return analyzer.getDocumentCount();
171+
}
172+
return 0;
103173
}
104174

105175
/** Get property names at a given schema path (for table headers). */
@@ -176,6 +246,8 @@ export class SchemaStore implements vscode.Disposable {
176246
}
177247
}
178248
ctx.telemetry.measurements.distinctClusterCount = clusterIds.size;
249+
ctx.telemetry.measurements.evictionCount = this._evictionCount;
250+
ctx.telemetry.measurements.maxEntries = this._maxEntries;
179251
});
180252
}
181253

@@ -205,8 +277,10 @@ export class SchemaStore implements vscode.Disposable {
205277
if (!analyzer) {
206278
analyzer = new SchemaAnalyzer();
207279
this._analyzers.set(key, analyzer);
280+
this._evictIfNeeded();
208281
}
209282

283+
this._touchKey(key);
210284
analyzer.addDocuments(documents);
211285
this._updateMaxStats();
212286
this._fireSchemaChanged(key, { clusterId, databaseName: db, collectionName: coll });
@@ -224,6 +298,7 @@ export class SchemaStore implements vscode.Disposable {
224298
public clearSchema(clusterId: string, db: string, coll: string): void {
225299
const key = SchemaStore.key(clusterId, db, coll);
226300
if (this._analyzers.delete(key)) {
301+
this._accessOrder.delete(key);
227302
// Cancel any pending debounced notification for this key
228303
const pending = this._pendingNotifications.get(key);
229304
if (pending !== undefined) {
@@ -240,6 +315,7 @@ export class SchemaStore implements vscode.Disposable {
240315
for (const key of this._analyzers.keys()) {
241316
if (key.startsWith(prefix)) {
242317
this._analyzers.delete(key);
318+
this._accessOrder.delete(key);
243319
const pending = this._pendingNotifications.get(key);
244320
if (pending !== undefined) {
245321
clearTimeout(pending);
@@ -255,6 +331,7 @@ export class SchemaStore implements vscode.Disposable {
255331
for (const key of this._analyzers.keys()) {
256332
if (key.startsWith(prefix)) {
257333
this._analyzers.delete(key);
334+
this._accessOrder.delete(key);
258335
const pending = this._pendingNotifications.get(key);
259336
if (pending !== undefined) {
260337
clearTimeout(pending);
@@ -267,6 +344,8 @@ export class SchemaStore implements vscode.Disposable {
267344
/** Clear all schemas (e.g., for testing). */
268345
public reset(): void {
269346
this._analyzers.clear();
347+
this._accessOrder.clear();
348+
this._evictionCount = 0;
270349
for (const timer of this._pendingNotifications.values()) {
271350
clearTimeout(timer);
272351
}
@@ -282,6 +361,7 @@ export class SchemaStore implements vscode.Disposable {
282361
clearTimeout(timer);
283362
}
284363
this._pendingNotifications.clear();
364+
this._accessOrder.clear();
285365
this._onDidChangeSchema.dispose();
286366
this._analyzers.clear();
287367
SchemaStore._instance = undefined;

0 commit comments

Comments
 (0)