@@ -117,7 +117,64 @@ export async function createDatabaseProvider(
117117// Provider Cache (for connection reuse)
118118// ============================================================================
119119
120- const providerCache = new Map < string , DatabaseProvider > ( ) ;
120+ interface CachedProvider {
121+ provider : DatabaseProvider ;
122+ lastUsed : number ;
123+ }
124+
125+ const providerCache = new Map < string , CachedProvider > ( ) ;
126+
127+ /** Idle timeout: evict providers unused for 30 minutes */
128+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000 ;
129+ /** Sweep interval: check for idle providers every 5 minutes */
130+ const SWEEP_INTERVAL_MS = 5 * 60 * 1000 ;
131+
132+ let sweepTimer : ReturnType < typeof setInterval > | null = null ;
133+
134+ /**
135+ * Evict providers that have been idle longer than maxIdleMs.
136+ * Called by the periodic sweep timer, but also exported for direct testing.
137+ *
138+ * @returns number of evicted providers
139+ */
140+ export async function evictIdleProviders ( maxIdleMs : number = IDLE_TIMEOUT_MS ) : Promise < number > {
141+ const now = Date . now ( ) ;
142+ let evicted = 0 ;
143+
144+ for ( const [ id , entry ] of providerCache ) {
145+ if ( now - entry . lastUsed >= maxIdleMs ) {
146+ logger . info ( `[DB] Evicting idle provider: ${ id } (idle ${ Math . round ( ( now - entry . lastUsed ) / 60000 ) } min)` ) ;
147+ try {
148+ await entry . provider . disconnect ( ) ;
149+ } catch ( error ) {
150+ logger . warn ( `[DB] Error disconnecting idle provider ${ id } ` , { connectionId : id , error : String ( error ) } ) ;
151+ }
152+ providerCache . delete ( id ) ;
153+ // Also close SSH tunnel
154+ try {
155+ await closeSSHTunnel ( id ) ;
156+ } catch { /* ignore */ }
157+ evicted ++ ;
158+ }
159+ }
160+
161+ // Stop sweeping if cache is empty
162+ if ( providerCache . size === 0 && sweepTimer ) {
163+ clearInterval ( sweepTimer ) ;
164+ sweepTimer = null ;
165+ }
166+
167+ return evicted ;
168+ }
169+
170+ function startIdleSweep ( ) : void {
171+ if ( sweepTimer ) return ;
172+ sweepTimer = setInterval ( ( ) => { evictIdleProviders ( ) ; } , SWEEP_INTERVAL_MS ) ;
173+ // Allow process to exit even if timer is running
174+ if ( sweepTimer && typeof sweepTimer === 'object' && 'unref' in sweepTimer ) {
175+ sweepTimer . unref ( ) ;
176+ }
177+ }
121178
122179/**
123180 * Get or create a database provider with caching
@@ -134,10 +191,11 @@ export async function getOrCreateProvider(
134191 const cacheKey = connection . id ;
135192
136193 // Check cache
137- let provider = providerCache . get ( cacheKey ) ;
194+ const cached = providerCache . get ( cacheKey ) ;
138195
139- if ( provider && provider . isConnected ( ) ) {
140- return provider ;
196+ if ( cached ?. provider . isConnected ( ) ) {
197+ cached . lastUsed = Date . now ( ) ;
198+ return cached . provider ;
141199 }
142200
143201 // If SSH tunnel is configured, create tunnel first and rewrite connection
@@ -159,7 +217,7 @@ export async function getOrCreateProvider(
159217 }
160218
161219 // Create new provider (async - dynamically loads the provider module)
162- provider = await createDatabaseProvider ( effectiveConnection , options ) ;
220+ const provider = await createDatabaseProvider ( effectiveConnection , options ) ;
163221 try {
164222 await provider . connect ( ) ;
165223 } catch ( error ) {
@@ -171,7 +229,10 @@ export async function getOrCreateProvider(
171229 }
172230
173231 // Cache it
174- providerCache . set ( cacheKey , provider ) ;
232+ providerCache . set ( cacheKey , { provider, lastUsed : Date . now ( ) } ) ;
233+
234+ // Start idle sweep if not already running
235+ startIdleSweep ( ) ;
175236
176237 return provider ;
177238}
@@ -180,11 +241,11 @@ export async function getOrCreateProvider(
180241 * Remove a provider from cache and disconnect
181242 */
182243export async function removeProvider ( connectionId : string ) : Promise < void > {
183- const provider = providerCache . get ( connectionId ) ;
244+ const cached = providerCache . get ( connectionId ) ;
184245
185- if ( provider ) {
246+ if ( cached ) {
186247 try {
187- await provider . disconnect ( ) ;
248+ await cached . provider . disconnect ( ) ;
188249 } catch ( error ) {
189250 logger . warn ( `Error disconnecting provider ${ connectionId } ` , { connectionId, error : String ( error ) } ) ;
190251 }
@@ -203,11 +264,17 @@ export async function removeProvider(connectionId: string): Promise<void> {
203264 * Clear all cached providers
204265 */
205266export async function clearProviderCache ( ) : Promise < void > {
267+ // Stop idle sweep
268+ if ( sweepTimer ) {
269+ clearInterval ( sweepTimer ) ;
270+ sweepTimer = null ;
271+ }
272+
206273 const disconnectPromises : Promise < void > [ ] = [ ] ;
207274
208- for ( const [ id , provider ] of providerCache ) {
275+ for ( const [ id , entry ] of providerCache ) {
209276 disconnectPromises . push (
210- provider . disconnect ( ) . catch ( ( error ) => {
277+ entry . provider . disconnect ( ) . catch ( ( error ) => {
211278 console . error ( `[DB] Error disconnecting provider ${ id } :` , error ) ;
212279 } )
213280 ) ;
@@ -226,3 +293,37 @@ export function getProviderCacheStats(): { size: number; connections: string[] }
226293 connections : Array . from ( providerCache . keys ( ) ) ,
227294 } ;
228295}
296+
297+ // ============================================================================
298+ // Graceful Shutdown
299+ // ============================================================================
300+
301+ let shutdownRegistered = false ;
302+
303+ /**
304+ * Register process signal handlers for graceful shutdown.
305+ * Safe to call multiple times — handlers are only registered once.
306+ */
307+ export function registerShutdownHandlers ( ) : void {
308+ if ( shutdownRegistered ) return ;
309+ shutdownRegistered = true ;
310+
311+ const shutdown = async ( signal : string ) => {
312+ logger . info ( `[DB] Received ${ signal } , closing all database connections...` ) ;
313+ try {
314+ await clearProviderCache ( ) ;
315+ logger . info ( '[DB] All database connections closed gracefully' ) ;
316+ } catch ( error ) {
317+ logger . error ( '[DB] Error during graceful shutdown' , { error : String ( error ) } ) ;
318+ }
319+ process . exit ( 0 ) ;
320+ } ;
321+
322+ process . on ( 'SIGTERM' , ( ) => shutdown ( 'SIGTERM' ) ) ;
323+ process . on ( 'SIGINT' , ( ) => shutdown ( 'SIGINT' ) ) ;
324+ }
325+
326+ // Auto-register on server-side (not during tests)
327+ if ( typeof process !== 'undefined' && process . env . NODE_ENV !== 'test' ) {
328+ registerShutdownHandlers ( ) ;
329+ }
0 commit comments