fix: critical memory leak from per-session database connections (#554)

* fix: critical memory leak from per-session database connections (#542)

Each MCP session was creating its own database connection (~900MB),
causing OOM kills every ~20 minutes with 3-4 concurrent sessions.

Changes:
- Add SharedDatabase singleton pattern - all sessions share ONE connection
- Reduce session timeout from 30 min to 5 min (configurable)
- Add eager cleanup for reconnecting instances
- Fix telemetry event listener leak

Memory impact: ~900MB/session → ~68MB shared + ~5MB/session overhead

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en

* fix: resolve test failures from shared database race conditions

- Fix `shutdown()` to respect shared database pattern (was directly closing)
- Add `await this.initialized` in both `close()` and `shutdown()` to prevent
  race condition where cleanup runs while initialization is in progress
- Add double-shutdown protection with `isShutdown` flag
- Export `SharedDatabaseState` type for proper typing
- Include error details in debug logs
- Add MCP server close to `shutdown()` for consistency with `close()`
- Null out `earlyLogger` in `shutdown()` for consistency

The CI test failure "The database connection is not open" was caused by:
1. `shutdown()` directly calling `this.db.close()` which closed the SHARED
   database connection, breaking subsequent tests
2. Race condition where `shutdown()` ran before initialization completed

Conceived by Romuald Członkowski - www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add unit tests for shared-database module

Add comprehensive unit tests covering:
- getSharedDatabase: initialization, reuse, different path error, concurrent requests
- releaseSharedDatabase: refCount decrement, double-release guard
- closeSharedDatabase: state clearing, error handling, re-initialization
- Helper functions: isSharedDatabaseInitialized, getSharedDatabaseRefCount

21 tests covering the singleton database connection pattern used to prevent
~900MB memory leaks per session.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2026-01-23 19:51:22 +01:00
committed by GitHub
parent fad3437977
commit c8c76e435d
9 changed files with 761 additions and 45 deletions

View File

@@ -14,6 +14,7 @@ import { getWorkflowExampleString } from './workflow-examples';
import { logger } from '../utils/logger';
import { NodeRepository } from '../database/node-repository';
import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter';
import { getSharedDatabase, releaseSharedDatabase, SharedDatabaseState } from '../database/shared-database';
import { PropertyFilter } from '../services/property-filter';
import { TaskTemplates } from '../services/task-templates';
import { ConfigValidator } from '../services/config-validator';
@@ -150,6 +151,9 @@ export class N8NDocumentationMCPServer {
private previousToolTimestamp: number = Date.now();
private earlyLogger: EarlyErrorLogger | null = null;
private disabledToolsCache: Set<string> | null = null;
private useSharedDatabase: boolean = false; // Track if using shared DB for cleanup
private sharedDbState: SharedDatabaseState | null = null; // Reference to shared DB state for release
private isShutdown: boolean = false; // Prevent double-shutdown
constructor(instanceContext?: InstanceContext, earlyLogger?: EarlyErrorLogger) {
this.instanceContext = instanceContext;
@@ -245,18 +249,39 @@ export class N8NDocumentationMCPServer {
* Order of cleanup:
* 1. Close MCP server connection
* 2. Destroy cache (clears entries AND stops cleanup timer)
* 3. Close database connection
* 3. Release shared database OR close dedicated connection
* 4. Null out references to help GC
*
* IMPORTANT: For shared databases, we only release the reference (decrement refCount),
* NOT close the database. The database stays open for other sessions.
* For in-memory databases (tests), we close the dedicated connection.
*/
async close(): Promise<void> {
// Wait for initialization to complete (or fail) before cleanup
// This prevents race conditions where close runs while init is in progress
try {
await this.initialized;
} catch (error) {
// Initialization failed - that's OK, we still need to clean up
logger.debug('Initialization had failed, proceeding with cleanup', {
error: error instanceof Error ? error.message : String(error)
});
}
try {
await this.server.close();
// Use destroy() not clear() - also stops the cleanup timer
this.cache.destroy();
// Close database connection before nullifying reference
if (this.db) {
// Handle database cleanup based on whether it's shared or dedicated
if (this.useSharedDatabase && this.sharedDbState) {
// Shared database: release reference, don't close
// The database stays open for other sessions
releaseSharedDatabase(this.sharedDbState);
logger.debug('Released shared database reference');
} else if (this.db) {
// Dedicated database (in-memory for tests): close it
try {
this.db.close();
} catch (dbError) {
@@ -271,6 +296,7 @@ export class N8NDocumentationMCPServer {
this.repository = null;
this.templateService = null;
this.earlyLogger = null;
this.sharedDbState = null;
} catch (error) {
// Log but don't throw - cleanup should be best-effort
logger.warn('Error closing MCP server', { error: error instanceof Error ? error.message : String(error) });
@@ -286,23 +312,32 @@ export class N8NDocumentationMCPServer {
logger.debug('Database initialization starting...', { dbPath });
this.db = await createDatabaseAdapter(dbPath);
logger.debug('Database adapter created');
// If using in-memory database for tests, initialize schema
// For in-memory databases (tests), create a dedicated connection
// For regular databases, use the shared connection to prevent memory leaks
if (dbPath === ':memory:') {
this.db = await createDatabaseAdapter(dbPath);
logger.debug('Database adapter created (in-memory mode)');
await this.initializeInMemorySchema();
logger.debug('In-memory schema initialized');
this.repository = new NodeRepository(this.db);
this.templateService = new TemplateService(this.db);
// Initialize similarity services for enhanced validation
EnhancedConfigValidator.initializeSimilarityServices(this.repository);
this.useSharedDatabase = false;
} else {
// Use shared database connection to prevent ~900MB memory leak per session
// See: Memory leak fix - database was being duplicated per session
const sharedState = await getSharedDatabase(dbPath);
this.db = sharedState.db;
this.repository = sharedState.repository;
this.templateService = sharedState.templateService;
this.sharedDbState = sharedState;
this.useSharedDatabase = true;
logger.debug('Using shared database connection');
}
this.repository = new NodeRepository(this.db);
logger.debug('Node repository initialized');
this.templateService = new TemplateService(this.db);
logger.debug('Template service initialized');
// Initialize similarity services for enhanced validation
EnhancedConfigValidator.initializeSimilarityServices(this.repository);
logger.debug('Similarity services initialized');
// Checkpoint: Database connected (v2.18.3)
@@ -3910,8 +3945,33 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
}
async shutdown(): Promise<void> {
// Prevent double-shutdown
if (this.isShutdown) {
logger.debug('Shutdown already called, skipping');
return;
}
this.isShutdown = true;
logger.info('Shutting down MCP server...');
// Wait for initialization to complete (or fail) before cleanup
// This prevents race conditions where shutdown runs while init is in progress
try {
await this.initialized;
} catch (error) {
// Initialization failed - that's OK, we still need to clean up
logger.debug('Initialization had failed, proceeding with cleanup', {
error: error instanceof Error ? error.message : String(error)
});
}
// Close MCP server connection (for consistency with close() method)
try {
await this.server.close();
} catch (error) {
logger.error('Error closing MCP server:', error);
}
// Clean up cache timers to prevent memory leaks
if (this.cache) {
try {
@@ -3921,15 +3981,31 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
logger.error('Error cleaning up cache:', error);
}
}
// Close database connection if it exists
if (this.db) {
// Handle database cleanup based on whether it's shared or dedicated
// For shared databases, we only release the reference (decrement refCount)
// For dedicated databases (in-memory for tests), we close the connection
if (this.useSharedDatabase && this.sharedDbState) {
try {
await this.db.close();
releaseSharedDatabase(this.sharedDbState);
logger.info('Released shared database reference');
} catch (error) {
logger.error('Error releasing shared database:', error);
}
} else if (this.db) {
try {
this.db.close();
logger.info('Database connection closed');
} catch (error) {
logger.error('Error closing database:', error);
}
}
// Null out references to help garbage collection
this.db = null;
this.repository = null;
this.templateService = null;
this.earlyLogger = null;
this.sharedDbState = null;
}
}