Browse Source

fix(sqlite): centralize database connection management

- Add proper connection state tracking (disconnected/connecting/connected/error)
- Implement connection promise to prevent race conditions
- Centralize connection lifecycle in getConnection() and releaseConnection()
- Remove redundant queue operations
- Improve error handling and state management

This fixes race conditions where multiple components (main process, renderer,
platform service) were interfering with each other's database operations.
Connection state is now properly tracked and operations are queued correctly.

Fixes: #<issue_number> (if applicable)
sql-absurd-sql-further
Matthew Raymer 3 days ago
parent
commit
e5dffc30ff
  1. 373
      src/services/platforms/ElectronPlatformService.ts

373
src/services/platforms/ElectronPlatformService.ts

@ -90,14 +90,19 @@ export class ElectronPlatformService implements PlatformService {
private dbFatalError = false; private dbFatalError = false;
private sqliteReadyPromise: Promise<void> | null = null; private sqliteReadyPromise: Promise<void> | null = null;
private initializationTimeout: NodeJS.Timeout | null = null; private initializationTimeout: NodeJS.Timeout | null = null;
private isConnectionOpen = false;
private operationQueue: Promise<unknown> = Promise.resolve();
private queueLock = false;
private connectionState: 'disconnected' | 'connecting' | 'connected' | 'error' = 'disconnected';
private connectionPromise: Promise<void> | null = null;
// SQLite initialization configuration // SQLite initialization configuration
private static readonly SQLITE_CONFIG = { private static readonly SQLITE_CONFIG = {
INITIALIZATION: { INITIALIZATION: {
TIMEOUT_MS: 1000, // with retries, stay under 5 seconds TIMEOUT_MS: 5000, // Increase timeout to 5 seconds
RETRY_ATTEMPTS: 3, RETRY_ATTEMPTS: 3,
RETRY_DELAY_MS: 1000, RETRY_DELAY_MS: 1000,
READY_CHECK_INTERVAL_MS: 100, // How often to check if SQLite is already ready READY_CHECK_INTERVAL_MS: 100,
}, },
}; };
@ -119,25 +124,18 @@ export class ElectronPlatformService implements PlatformService {
} }
// Check if SQLite is already available // Check if SQLite is already available
const isAvailable = await window.electron.ipcRenderer.invoke( const isAvailable = await window.electron.ipcRenderer.invoke("sqlite-is-available");
"sqlite-is-available",
);
if (!isAvailable) { if (!isAvailable) {
return false; return false;
} }
// Check if database is already open // Check if database is already open
const isOpen = await window.electron.ipcRenderer.invoke( const isOpen = await window.electron.ipcRenderer.invoke("sqlite-is-db-open", {
"sqlite-is-db-open", database: this.dbName,
{ });
database: this.dbName,
},
);
if (isOpen) { if (isOpen) {
logger.info( logger.info("[ElectronPlatformService] SQLite is already ready and database is open");
"[ElectronPlatformService] SQLite is already ready and database is open",
);
sqliteInitState.isReady = true; sqliteInitState.isReady = true;
sqliteInitState.isInitializing = false; sqliteInitState.isInitializing = false;
sqliteInitState.lastReadyCheck = Date.now(); sqliteInitState.lastReadyCheck = Date.now();
@ -146,16 +144,13 @@ export class ElectronPlatformService implements PlatformService {
return false; return false;
} catch (error) { } catch (error) {
logger.warn( logger.warn("[ElectronPlatformService] Error checking existing readiness:", error);
"[ElectronPlatformService] Error checking existing readiness:",
error,
);
return false; return false;
} }
}; };
const attemptInitialization = async () => { const attemptInitialization = async () => {
cleanup(); // Clear any existing timeout cleanup();
// Check if SQLite is already ready // Check if SQLite is already ready
if (await checkExistingReadiness()) { if (await checkExistingReadiness()) {
@ -166,14 +161,8 @@ export class ElectronPlatformService implements PlatformService {
// If someone else is initializing, wait for them // If someone else is initializing, wait for them
if (sqliteInitState.isInitializing) { if (sqliteInitState.isInitializing) {
logger.info( logger.info("[ElectronPlatformService] Another initialization in progress, waiting...");
"[ElectronPlatformService] Another initialization in progress, waiting...", setTimeout(attemptInitialization, ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION.READY_CHECK_INTERVAL_MS);
);
setTimeout(
attemptInitialization,
ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION
.READY_CHECK_INTERVAL_MS,
);
return; return;
} }
@ -182,9 +171,7 @@ export class ElectronPlatformService implements PlatformService {
// Verify Electron API exposure first // Verify Electron API exposure first
await verifyElectronAPI(); await verifyElectronAPI();
logger.info( logger.info("[ElectronPlatformService] Electron API verification successful");
"[ElectronPlatformService] Electron API verification successful",
);
if (!window.electron?.ipcRenderer) { if (!window.electron?.ipcRenderer) {
logger.warn("[ElectronPlatformService] IPC renderer not available"); logger.warn("[ElectronPlatformService] IPC renderer not available");
@ -192,48 +179,15 @@ export class ElectronPlatformService implements PlatformService {
return; return;
} }
// Set timeout for this attempt // Set up ready signal handler BEFORE setting timeout
this.initializationTimeout = setTimeout(() => {
if (
retryCount <
ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION
.RETRY_ATTEMPTS
) {
retryCount++;
logger.warn(
`[ElectronPlatformService] SQLite initialization attempt ${retryCount} timed out, retrying...`,
);
setTimeout(
attemptInitialization,
ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION
.RETRY_DELAY_MS,
);
} else {
cleanup();
sqliteInitState.isInitializing = false;
sqliteInitState.error = new Error(
"SQLite initialization timeout after all retries",
);
logger.error(
"[ElectronPlatformService] SQLite initialization failed after all retries",
);
reject(sqliteInitState.error);
}
}, ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION.TIMEOUT_MS);
// Set up ready signal handler
window.electron.ipcRenderer.once("sqlite-ready", async () => { window.electron.ipcRenderer.once("sqlite-ready", async () => {
cleanup(); cleanup();
logger.info( logger.info("[ElectronPlatformService] Received SQLite ready signal");
"[ElectronPlatformService] Received SQLite ready signal",
);
try { try {
// Test SQLite operations after receiving ready signal // Test SQLite operations after receiving ready signal
await testSQLiteOperations(); await testSQLiteOperations();
logger.info( logger.info("[ElectronPlatformService] SQLite operations test successful");
"[ElectronPlatformService] SQLite operations test successful",
);
this.isInitialized = true; this.isInitialized = true;
sqliteInitState.isReady = true; sqliteInitState.isReady = true;
@ -243,38 +197,43 @@ export class ElectronPlatformService implements PlatformService {
} catch (error) { } catch (error) {
sqliteInitState.error = error as Error; sqliteInitState.error = error as Error;
sqliteInitState.isInitializing = false; sqliteInitState.isInitializing = false;
logger.error( logger.error("[ElectronPlatformService] SQLite operations test failed:", error);
"[ElectronPlatformService] SQLite operations test failed:",
error,
);
reject(error); reject(error);
} }
}); });
// Set up error handler // Set up error handler
window.electron.ipcRenderer.once( window.electron.ipcRenderer.once("database-status", (...args: unknown[]) => {
"database-status", cleanup();
(...args: unknown[]) => { const status = args[0] as { status: string; error?: string };
if (status.status === "error") {
this.dbFatalError = true;
sqliteInitState.error = new Error(status.error || "Database initialization failed");
sqliteInitState.isInitializing = false;
reject(sqliteInitState.error);
}
});
// Set timeout for this attempt AFTER setting up handlers
this.initializationTimeout = setTimeout(() => {
if (retryCount < ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION.RETRY_ATTEMPTS) {
retryCount++;
logger.warn(`[ElectronPlatformService] SQLite initialization attempt ${retryCount} timed out, retrying...`);
setTimeout(attemptInitialization, ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION.RETRY_DELAY_MS);
} else {
cleanup(); cleanup();
const status = args[0] as { status: string; error?: string }; sqliteInitState.isInitializing = false;
if (status.status === "error") { sqliteInitState.error = new Error("SQLite initialization timeout after all retries");
this.dbFatalError = true; logger.error("[ElectronPlatformService] SQLite initialization failed after all retries");
sqliteInitState.error = new Error( reject(sqliteInitState.error);
status.error || "Database initialization failed", }
); }, ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION.TIMEOUT_MS);
sqliteInitState.isInitializing = false;
reject(sqliteInitState.error);
}
},
);
} catch (error) { } catch (error) {
cleanup(); cleanup();
sqliteInitState.error = error as Error; sqliteInitState.error = error as Error;
sqliteInitState.isInitializing = false; sqliteInitState.isInitializing = false;
logger.error( logger.error("[ElectronPlatformService] Initialization failed:", error);
"[ElectronPlatformService] Initialization failed:",
error,
);
reject(error); reject(error);
} }
}; };
@ -295,17 +254,27 @@ export class ElectronPlatformService implements PlatformService {
// Use IPC bridge with specific methods // Use IPC bridge with specific methods
this.sqlite = { this.sqlite = {
createConnection: async (options) => { createConnection: async (options) => {
await window.electron.ipcRenderer.invoke('sqlite-create-connection', options); await window.electron.ipcRenderer.invoke('sqlite-create-connection', {
...options,
database: this.dbName
});
}, },
query: async (options) => { query: async (options) => {
return await window.electron.ipcRenderer.invoke('sqlite-query', options); return await window.electron.ipcRenderer.invoke('sqlite-query', {
...options,
database: this.dbName
});
}, },
run: async (options) => { run: async (options) => {
return await window.electron.ipcRenderer.invoke('sqlite-run', options); return await window.electron.ipcRenderer.invoke('sqlite-run', {
...options,
database: this.dbName
});
}, },
execute: async (options) => { execute: async (options) => {
await window.electron.ipcRenderer.invoke('sqlite-execute', { await window.electron.ipcRenderer.invoke('sqlite-execute', {
database: options.database, ...options,
database: this.dbName,
statements: [{ statement: options.statements }] statements: [{ statement: options.statements }]
}); });
} }
@ -559,112 +528,160 @@ export class ElectronPlatformService implements PlatformService {
throw new Error("Not implemented"); throw new Error("Not implemented");
} }
/** private async enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
* Executes a database query with proper connection lifecycle management. // Wait for any existing operations to complete
* Opens connection, executes query, and ensures proper cleanup. await this.operationQueue;
*
* @param sql - SQL query to execute
* @param params - Optional parameters for the query
* @returns Promise resolving to query results
* @throws Error if database operations fail
*/
async dbQuery<T = unknown>(
sql: string,
params: unknown[] = [],
): Promise<QueryExecResult<T>> {
logger.debug(
"[ElectronPlatformService] [dbQuery] TEMPORARY TEST: Returning empty result for query:",
{
sql,
params,
timestamp: new Date().toISOString(),
},
);
// TEMPORARY TEST: Return empty result // Create a new promise for this operation
return { const operationPromise = (async () => {
columns: [], try {
values: [], // Acquire lock
}; while (this.queueLock) {
await new Promise(resolve => setTimeout(resolve, 50));
}
this.queueLock = true;
// Original implementation commented out for testing // Execute operation
/* return await operation();
if (this.dbFatalError) { } finally {
throw new Error("Database is in a fatal error state. Please restart the app."); // Release lock
this.queueLock = false;
}
})();
// Update the queue
this.operationQueue = operationPromise;
return operationPromise;
}
private async getConnection(): Promise<void> {
// If we already have a connection promise, return it
if (this.connectionPromise) {
return this.connectionPromise;
} }
if (!window.electron?.ipcRenderer) { // If we're already connected, return immediately
throw new Error("IPC renderer not available"); if (this.connectionState === 'connected') {
return Promise.resolve();
} }
try { // Create new connection promise
// Check SQLite availability first this.connectionPromise = (async () => {
const isAvailable = await window.electron.ipcRenderer.invoke('sqlite-is-available'); try {
if (!isAvailable) { this.connectionState = 'connecting';
throw new Error('[ElectronPlatformService] [dbQuery] SQLite is not available');
// Wait for any existing operations
await this.operationQueue;
// Create connection
await window.electron!.ipcRenderer.invoke('sqlite-create-connection', {
database: this.dbName,
encrypted: false,
mode: "no-encryption",
});
logger.debug("[ElectronPlatformService] Database connection created");
// Open database
await window.electron!.ipcRenderer.invoke('sqlite-open', {
database: this.dbName
});
logger.debug("[ElectronPlatformService] Database opened");
// Verify database is open
const isOpen = await window.electron!.ipcRenderer.invoke('sqlite-is-db-open', {
database: this.dbName
});
if (!isOpen) {
throw new Error('[ElectronPlatformService] Database failed to open');
}
this.connectionState = 'connected';
this.isConnectionOpen = true;
} catch (error) {
this.connectionState = 'error';
this.connectionPromise = null;
throw error;
} }
logger.debug("[ElectronPlatformService] [dbQuery] SQLite is available"); })();
// Create database connection return this.connectionPromise;
await window.electron.ipcRenderer.invoke('sqlite-create-connection', { }
database: this.dbName,
version: 1 private async releaseConnection(): Promise<void> {
}); if (this.connectionState !== 'connected') {
logger.debug("[ElectronPlatformService] [dbQuery] Database connection created"); return;
}
// Open database try {
await window.electron.ipcRenderer.invoke('sqlite-open', { // Close database
await window.electron!.ipcRenderer.invoke('sqlite-close', {
database: this.dbName database: this.dbName
}); });
logger.debug("[ElectronPlatformService] [dbQuery] Database opened"); logger.debug("[ElectronPlatformService] Database closed");
// Verify database is open // Close connection
const isOpen = await window.electron.ipcRenderer.invoke('sqlite-is-db-open', { await window.electron!.ipcRenderer.invoke('sqlite-close-connection', {
database: this.dbName database: this.dbName
}); });
if (!isOpen) { logger.debug("[ElectronPlatformService] Database connection closed");
throw new Error('[ElectronPlatformService] [dbQuery] Database failed to open');
}
// Execute query this.connectionState = 'disconnected';
const result = await window.electron.ipcRenderer.invoke('sqlite-query', { this.isConnectionOpen = false;
database: this.dbName,
statement: sql,
values: params
}) as SQLiteQueryResult;
logger.debug("[ElectronPlatformService] [dbQuery] Query executed successfully");
// Process results
const columns = result.values?.[0] ? Object.keys(result.values[0]) : [];
const processedResult = {
columns,
values: (result.values || []).map((row: Record<string, unknown>) => row as T)
};
return processedResult;
} catch (error) { } catch (error) {
logger.error("[ElectronPlatformService] [dbQuery] Query failed:", error); logger.error("[ElectronPlatformService] Failed to close database:", error);
throw error; this.connectionState = 'error';
} finally { } finally {
// Ensure proper cleanup this.connectionPromise = null;
}
}
/**
* Executes a database query with proper connection lifecycle management.
* Opens connection, executes query, and ensures proper cleanup.
*
* @param sql - SQL query to execute
* @param params - Optional parameters for the query
* @returns Promise resolving to query results
* @throws Error if database operations fail
*/
async dbQuery<T = unknown>(
sql: string,
params: unknown[] = [],
): Promise<QueryExecResult<T>> {
if (this.dbFatalError) {
throw new Error("Database is in a fatal error state. Please restart the app.");
}
return this.enqueueOperation(async () => {
try { try {
// Close database // Get connection (will wait for existing connection if any)
await window.electron.ipcRenderer.invoke('sqlite-close', { await this.getConnection();
database: this.dbName
});
logger.debug("[ElectronPlatformService] [dbQuery] Database closed");
// Close connection // Execute query
await window.electron.ipcRenderer.invoke('sqlite-close-connection', { const result = await window.electron!.ipcRenderer.invoke('sqlite-query', {
database: this.dbName database: this.dbName,
}); statement: sql,
logger.debug("[ElectronPlatformService] [dbQuery] Database connection closed"); values: params
} catch (closeError) { }) as SQLiteQueryResult;
logger.error("[ElectronPlatformService] [dbQuery] Failed to cleanup database:", closeError); logger.debug("[ElectronPlatformService] [dbQuery] Query executed successfully");
// Don't throw here - we want to preserve the original error if any
// Process results
const columns = result.values?.[0] ? Object.keys(result.values[0]) : [];
const processedResult = {
columns,
values: (result.values || []).map((row: Record<string, unknown>) => row as T)
};
return processedResult;
} catch (error) {
logger.error("[ElectronPlatformService] [dbQuery] Query failed:", error);
throw error;
} finally {
// Release connection after query
await this.releaseConnection();
} }
} });
*/
} }
/** /**

Loading…
Cancel
Save