@@ -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 */
5053export 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