@@ -56,25 +56,32 @@ type StoredExport = ReadyExport | InProgressExport;
5656 * JIRA: https://jira.mongodb.org/browse/MCP-104 */
5757type AvailableExport = Pick < StoredExport , "exportName" | "exportTitle" | "exportURI" | "exportPath" > ;
5858
59- export type ExportsManagerConfig = Pick < UserConfig , "exportsPath" | "exportTimeoutMs" | "exportCleanupIntervalMs" > ;
59+ export type ExportsManagerConfig = Pick < UserConfig , "exportsPath" | "exportTimeoutMs" | "exportCleanupIntervalMs" > & {
60+ // The maximum number of milliseconds to wait for in-flight operations to
61+ // settle before shutting down ExportsManager.
62+ activeOpsDrainTimeoutMs ?: number ;
63+ } ;
6064
6165type ExportsManagerEvents = {
6266 "export-expired" : [ string ] ;
6367 "export-available" : [ string ] ;
6468} ;
6569
6670export class ExportsManager extends EventEmitter < ExportsManagerEvents > {
67- private isShuttingDown : boolean = false ;
6871 private storedExports : Record < StoredExport [ "exportName" ] , StoredExport > = { } ;
6972 private exportsCleanupInProgress : boolean = false ;
7073 private exportsCleanupInterval ?: NodeJS . Timeout ;
74+ private readonly shutdownController : AbortController = new AbortController ( ) ;
75+ private readonly activeOperations : Set < Promise < unknown > > = new Set ( ) ;
76+ private readonly activeOpsDrainTimeoutMs : number ;
7177
7278 private constructor (
7379 private readonly exportsDirectoryPath : string ,
7480 private readonly config : ExportsManagerConfig ,
7581 private readonly logger : LoggerBase
7682 ) {
7783 super ( ) ;
84+ this . activeOpsDrainTimeoutMs = this . config . activeOpsDrainTimeoutMs ?? 10_000 ;
7885 }
7986
8087 public get availableExports ( ) : AvailableExport [ ] {
@@ -97,20 +104,19 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
97104 protected init ( ) : void {
98105 if ( ! this . exportsCleanupInterval ) {
99106 this . exportsCleanupInterval = setInterval (
100- ( ) => void this . cleanupExpiredExports ( ) ,
107+ ( ) => void this . trackOperation ( this . cleanupExpiredExports ( ) ) ,
101108 this . config . exportCleanupIntervalMs
102109 ) ;
103110 }
104111 }
105-
106112 public async close ( ) : Promise < void > {
107- if ( this . isShuttingDown ) {
113+ if ( this . shutdownController . signal . aborted ) {
108114 return ;
109115 }
110-
111- this . isShuttingDown = true ;
112116 try {
113117 clearInterval ( this . exportsCleanupInterval ) ;
118+ this . shutdownController . abort ( ) ;
119+ await this . waitForActiveOperationsToSettle ( this . activeOpsDrainTimeoutMs ) ;
114120 await fs . rm ( this . exportsDirectoryPath , { force : true , recursive : true } ) ;
115121 } catch ( error ) {
116122 this . logger . error ( {
@@ -140,7 +146,9 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
140146 throw new Error ( "Requested export has expired!" ) ;
141147 }
142148
143- return await fs . readFile ( exportPath , "utf8" ) ;
149+ return await this . trackOperation (
150+ fs . readFile ( exportPath , { encoding : "utf8" , signal : this . shutdownController . signal } )
151+ ) ;
144152 } catch ( error ) {
145153 this . logger . error ( {
146154 id : LogId . exportReadError ,
@@ -181,7 +189,7 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
181189 exportStatus : "in-progress" ,
182190 } ) ;
183191
184- void this . startExport ( { input, jsonExportFormat, inProgressExport } ) ;
192+ void this . trackOperation ( this . startExport ( { input, jsonExportFormat, inProgressExport } ) ) ;
185193 return inProgressExport ;
186194 } catch ( error ) {
187195 this . logger . error ( {
@@ -206,11 +214,10 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
206214 try {
207215 await fs . mkdir ( this . exportsDirectoryPath , { recursive : true } ) ;
208216 const outputStream = createWriteStream ( inProgressExport . exportPath ) ;
209- await pipeline ( [
210- input . stream ( ) ,
211- this . docToEJSONStream ( this . getEJSONOptionsForFormat ( jsonExportFormat ) ) ,
212- outputStream ,
213- ] ) ;
217+ await pipeline (
218+ [ input . stream ( ) , this . docToEJSONStream ( this . getEJSONOptionsForFormat ( jsonExportFormat ) ) , outputStream ] ,
219+ { signal : this . shutdownController . signal }
220+ ) ;
214221 pipeSuccessful = true ;
215222 } catch ( error ) {
216223 this . logger . error ( {
@@ -281,7 +288,7 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
281288 }
282289
283290 private async cleanupExpiredExports ( ) : Promise < void > {
284- if ( this . exportsCleanupInProgress || this . isShuttingDown ) {
291+ if ( this . exportsCleanupInProgress ) {
285292 return ;
286293 }
287294
@@ -291,6 +298,9 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
291298 ) ;
292299 try {
293300 for ( const { exportPath, exportCreatedAt, exportURI, exportName } of exportsForCleanup ) {
301+ if ( this . shutdownController . signal . aborted ) {
302+ break ;
303+ }
294304 if ( isExportExpired ( exportCreatedAt , this . config . exportTimeoutMs ) ) {
295305 delete this . storedExports [ exportName ] ;
296306 await this . silentlyRemoveExport (
@@ -330,11 +340,42 @@ export class ExportsManager extends EventEmitter<ExportsManagerEvents> {
330340 }
331341
332342 private assertIsNotShuttingDown ( ) : void {
333- if ( this . isShuttingDown ) {
343+ if ( this . shutdownController . signal . aborted ) {
334344 throw new Error ( "ExportsManager is shutting down." ) ;
335345 }
336346 }
337347
348+ private async trackOperation < T > ( promise : Promise < T > ) : Promise < T > {
349+ this . activeOperations . add ( promise ) ;
350+ try {
351+ return await promise ;
352+ } finally {
353+ this . activeOperations . delete ( promise ) ;
354+ }
355+ }
356+
357+ private async waitForActiveOperationsToSettle ( timeoutMs : number ) : Promise < void > {
358+ const pendingPromises = Array . from ( this . activeOperations ) ;
359+ if ( pendingPromises . length === 0 ) {
360+ return ;
361+ }
362+ let timedOut = false ;
363+ const timeoutPromise = new Promise < void > ( ( resolve ) =>
364+ setTimeout ( ( ) => {
365+ timedOut = true ;
366+ resolve ( ) ;
367+ } , timeoutMs )
368+ ) ;
369+ await Promise . race ( [ Promise . allSettled ( pendingPromises ) , timeoutPromise ] ) ;
370+ if ( timedOut && this . activeOperations . size > 0 ) {
371+ this . logger . error ( {
372+ id : LogId . exportCloseError ,
373+ context : `Close timed out waiting for ${ this . activeOperations . size } operation(s) to settle` ,
374+ message : "Proceeding to force cleanup after timeout" ,
375+ } ) ;
376+ }
377+ }
378+
338379 static init (
339380 config : ExportsManagerConfig ,
340381 logger : LoggerBase ,
0 commit comments