Skip to content

Commit 85cb524

Browse files
chore: track long running ops and abort them on close
1 parent a3df32c commit 85cb524

File tree

2 files changed

+96
-16
lines changed

2 files changed

+96
-16
lines changed

src/common/exportsManager.ts

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -56,25 +56,32 @@ type StoredExport = ReadyExport | InProgressExport;
5656
* JIRA: https://jira.mongodb.org/browse/MCP-104 */
5757
type 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

6165
type ExportsManagerEvents = {
6266
"export-expired": [string];
6367
"export-available": [string];
6468
};
6569

6670
export 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,

tests/unit/common/exportsManager.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,45 @@ describe("ExportsManager unit test", () => {
514514
expect(await fileExists(exportPath)).toEqual(false);
515515
});
516516
});
517+
518+
describe("#close", () => {
519+
it("should abort ongoing export and remove partial file", async () => {
520+
const { exportName, exportPath } = getExportNameAndPath(session.sessionId);
521+
const { cursor } = createDummyFindCursorWithDelay([{ name: "Test" }], 2000);
522+
manager.createJSONExport({
523+
input: cursor,
524+
exportName,
525+
exportTitle: "Some export",
526+
jsonExportFormat: "relaxed",
527+
});
528+
// Give the pipeline a brief moment to start and create the file
529+
await timeout(50);
530+
531+
await manager.close();
532+
533+
await expect(fileExists(exportPath)).resolves.toEqual(false);
534+
});
535+
536+
it("should timeout shutdown wait when operations never settle", async () => {
537+
await manager.close();
538+
const logger = new CompositeLogger();
539+
manager = ExportsManager.init({ ...exportsManagerConfig, activeOpsDrainTimeoutMs: 50 }, logger);
540+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
541+
(manager as any).activeOperations.add(
542+
new Promise<void>(() => {
543+
/* never resolves */
544+
})
545+
);
546+
547+
const start = Date.now();
548+
await manager.close();
549+
const elapsed = Date.now() - start;
550+
551+
// Should not block indefinitely; should be close to the configured timeout but well under 1s
552+
expect(elapsed).toBeGreaterThanOrEqual(45);
553+
expect(elapsed).toBeLessThan(1000);
554+
});
555+
});
517556
});
518557

519558
describe("#ensureExtension", () => {

0 commit comments

Comments
 (0)