Skip to content

Commit da92533

Browse files
authored
Merge pull request #43 from libredb/dev
fix: connection lifecycle bugs — leak, shutdown, eviction, timeout
2 parents ab2f334 + bd62c50 commit da92533

13 files changed

Lines changed: 458 additions & 18 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "libredb-studio",
3-
"version": "0.8.7",
3+
"version": "0.8.10",
44
"private": true,
55
"scripts": {
66
"dev": "next dev",

src/app/api/db/disconnect/route.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { removeProvider } from '@/lib/db/factory';
3+
import { logger } from '@/lib/logger';
4+
5+
export async function POST(req: NextRequest) {
6+
try {
7+
const { connectionId } = await req.json();
8+
9+
if (!connectionId || typeof connectionId !== 'string') {
10+
return NextResponse.json(
11+
{ success: false, error: 'connectionId is required' },
12+
{ status: 400 }
13+
);
14+
}
15+
16+
await removeProvider(connectionId);
17+
18+
logger.info('[DB] Provider disconnected and removed from cache', { connectionId });
19+
20+
return NextResponse.json({ success: true });
21+
} catch (error) {
22+
logger.error('[DB] Error disconnecting provider', { error: String(error) });
23+
return NextResponse.json(
24+
{ success: false, error: 'Failed to disconnect' },
25+
{ status: 500 }
26+
);
27+
}
28+
}

src/components/Studio.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,13 @@ export default function Studio() {
247247
};
248248

249249
const handleDeleteConnection = (id: string) => {
250+
// Clean up server-side provider cache and close connections/tunnels
251+
fetch('/api/db/disconnect', {
252+
method: 'POST',
253+
headers: { 'Content-Type': 'application/json' },
254+
body: JSON.stringify({ connectionId: id }),
255+
}).catch(() => { /* best-effort cleanup */ });
256+
250257
storage.deleteConnection(id);
251258
const updated = storage.getConnections();
252259
conn.setConnections(updated);

src/lib/db/factory.ts

Lines changed: 112 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
182243
export 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
*/
205266
export 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+
}

src/lib/db/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export {
2626
removeProvider,
2727
clearProviderCache,
2828
getProviderCacheStats,
29+
evictIdleProviders,
30+
registerShutdownHandlers,
2931
} from './factory';
3032

3133
// ============================================================================

src/lib/db/providers/document/mongodb.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,13 @@ export class MongoDBProvider extends BaseDatabaseProvider {
170170

171171
public async disconnect(): Promise<void> {
172172
if (this.client) {
173-
await this.client.close();
174-
this.client = null;
175-
this.db = null;
176-
this.setConnected(false);
173+
try {
174+
await this.client.close();
175+
} finally {
176+
this.client = null;
177+
this.db = null;
178+
this.setConnected(false);
179+
}
177180
}
178181
}
179182

src/lib/db/providers/keyvalue/redis.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,14 @@ export class RedisProvider extends BaseDatabaseProvider {
126126

127127
public async disconnect(): Promise<void> {
128128
if (this.client) {
129-
this.client.disconnect();
130-
this.client = null;
129+
try {
130+
await this.client.quit();
131+
} catch {
132+
// quit() may fail if already disconnected; force disconnect
133+
try { this.client.disconnect(); } catch { /* ignore */ }
134+
} finally {
135+
this.client = null;
136+
}
131137
}
132138
this.setConnected(false);
133139
}

src/lib/db/providers/sql/mysql.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export class MySQLProvider extends SQLBaseProvider {
4242
// Transaction support: dedicated connection held outside pool
4343
private txConn: PoolConnection | null = null;
4444
private txActive = false;
45+
private txTimeout: ReturnType<typeof setTimeout> | null = null;
46+
private static readonly TX_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
4547

4648
constructor(config: DatabaseConnection, options: ProviderOptions = {}) {
4749
super(config, options);
@@ -232,16 +234,45 @@ export class MySQLProvider extends SQLBaseProvider {
232234
// Transaction Support
233235
// ============================================================================
234236

237+
private clearTxTimeout(): void {
238+
if (this.txTimeout) {
239+
clearTimeout(this.txTimeout);
240+
this.txTimeout = null;
241+
}
242+
}
243+
244+
/**
245+
* Force-expire an active transaction (auto-rollback).
246+
* Called by the timeout timer, but also available for testing.
247+
*/
248+
public async expireTransaction(): Promise<void> {
249+
if (this.txActive && this.txConn) {
250+
console.warn('[MySQL] Transaction timed out, auto-rolling back');
251+
try {
252+
await this.txConn.rollback();
253+
} catch { /* ignore */ } finally {
254+
this.txConn.release();
255+
this.txConn = null;
256+
this.txActive = false;
257+
this.clearTxTimeout();
258+
}
259+
}
260+
}
261+
235262
public async beginTransaction(): Promise<void> {
236263
this.ensureConnected();
237264
if (this.txActive) throw new QueryError('Transaction already active', 'mysql');
238265
this.txConn = await this.pool!.getConnection();
239266
await this.txConn.beginTransaction();
240267
this.txActive = true;
268+
269+
// Auto-rollback after timeout to prevent leaked locks
270+
this.txTimeout = setTimeout(() => { this.expireTransaction(); }, MySQLProvider.TX_TIMEOUT_MS);
241271
}
242272

243273
public async commitTransaction(): Promise<void> {
244274
if (!this.txConn || !this.txActive) throw new QueryError('No active transaction', 'mysql');
275+
this.clearTxTimeout();
245276
try {
246277
await this.txConn.commit();
247278
} finally {
@@ -253,6 +284,7 @@ export class MySQLProvider extends SQLBaseProvider {
253284

254285
public async rollbackTransaction(): Promise<void> {
255286
if (!this.txConn || !this.txActive) throw new QueryError('No active transaction', 'mysql');
287+
this.clearTxTimeout();
256288
try {
257289
await this.txConn.rollback();
258290
} finally {

src/lib/db/providers/sql/postgres.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export class PostgresProvider extends SQLBaseProvider {
5858
// Transaction support: dedicated client held outside pool
5959
private txClient: PoolClient | null = null;
6060
private txActive = false;
61+
private txTimeout: ReturnType<typeof setTimeout> | null = null;
62+
private static readonly TX_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
6163

6264
constructor(config: DatabaseConnection, options: ProviderOptions = {}) {
6365
super(config, options);
@@ -252,16 +254,45 @@ export class PostgresProvider extends SQLBaseProvider {
252254
// Transaction Support
253255
// ============================================================================
254256

257+
private clearTxTimeout(): void {
258+
if (this.txTimeout) {
259+
clearTimeout(this.txTimeout);
260+
this.txTimeout = null;
261+
}
262+
}
263+
264+
/**
265+
* Force-expire an active transaction (auto-rollback).
266+
* Called by the timeout timer, but also available for testing.
267+
*/
268+
public async expireTransaction(): Promise<void> {
269+
if (this.txActive && this.txClient) {
270+
console.warn('[Postgres] Transaction timed out, auto-rolling back');
271+
try {
272+
await this.txClient.query('ROLLBACK');
273+
} catch { /* ignore */ } finally {
274+
this.txClient.release();
275+
this.txClient = null;
276+
this.txActive = false;
277+
this.clearTxTimeout();
278+
}
279+
}
280+
}
281+
255282
public async beginTransaction(): Promise<void> {
256283
this.ensureConnected();
257284
if (this.txActive) throw new QueryError('Transaction already active', 'postgres');
258285
this.txClient = await this.pool!.connect();
259286
await this.txClient.query('BEGIN');
260287
this.txActive = true;
288+
289+
// Auto-rollback after timeout to prevent leaked locks
290+
this.txTimeout = setTimeout(() => { this.expireTransaction(); }, PostgresProvider.TX_TIMEOUT_MS);
261291
}
262292

263293
public async commitTransaction(): Promise<void> {
264294
if (!this.txClient || !this.txActive) throw new QueryError('No active transaction', 'postgres');
295+
this.clearTxTimeout();
265296
try {
266297
await this.txClient.query('COMMIT');
267298
} finally {
@@ -273,6 +304,7 @@ export class PostgresProvider extends SQLBaseProvider {
273304

274305
public async rollbackTransaction(): Promise<void> {
275306
if (!this.txClient || !this.txActive) throw new QueryError('No active transaction', 'postgres');
307+
this.clearTxTimeout();
276308
try {
277309
await this.txClient.query('ROLLBACK');
278310
} finally {

0 commit comments

Comments
 (0)