diff --git a/packages/logger/src/transports/dbConnectionPool.ts b/packages/logger/src/transports/dbConnectionPool.ts new file mode 100644 index 0000000..4fb3676 --- /dev/null +++ b/packages/logger/src/transports/dbConnectionPool.ts @@ -0,0 +1,319 @@ +/* eslint-disable no-console */ +import Dexie from 'dexie'; +import { global } from '@ringcentral/mfe-shared'; + +export interface DBConnectionPoolOptions { + /** + * Maximum number of concurrent connections per database + */ + maxConnections?: number; + /** + * Connection idle timeout in milliseconds + */ + idleTimeout?: number; + /** + * Enable performance monitoring + */ + enablePerformanceMonitoring?: boolean; +} + +export interface DBPerformanceMetrics { + connectionCount: number; + activeConnections: number; + averageConnectionTime: number; + totalOperations: number; + failedOperations: number; + queuedOperations: number; + averageWriteTime: number; +} + +interface QueuedOperation { + resolve: (db: Dexie) => void; + reject: (error: Error) => void; + priority: number; + timestamp: number; +} + +interface ConnectionInfo { + db: Dexie; + lastUsed: number; + inUse: boolean; +} + +export class DBConnectionPool { + private static pools = new Map(); + + private connections: ConnectionInfo[] = []; + + private queue: QueuedOperation[] = []; + + private metrics: DBPerformanceMetrics = { + connectionCount: 0, + activeConnections: 0, + averageConnectionTime: 0, + totalOperations: 0, + failedOperations: 0, + queuedOperations: 0, + averageWriteTime: 0, + }; + + private readonly maxConnections: number; + + private readonly idleTimeout: number; + + private readonly enablePerformanceMonitoring: boolean; + + private cleanupTimer?: NodeJS.Timeout; + + constructor( + private dbName: string, + private dbConfig: { version: number; stores: Record }, + options: DBConnectionPoolOptions = {} + ) { + this.maxConnections = options.maxConnections ?? 2; // Conservative default + this.idleTimeout = options.idleTimeout ?? 30000; // 30 seconds + this.enablePerformanceMonitoring = + options.enablePerformanceMonitoring ?? false; + + // Start cleanup timer + this.startCleanupTimer(); + } + + static getInstance( + dbName: string, + dbConfig: { version: number; stores: Record }, + options: DBConnectionPoolOptions = {} + ): DBConnectionPool { + if (!this.pools.has(dbName)) { + this.pools.set(dbName, new DBConnectionPool(dbName, dbConfig, options)); + } + return this.pools.get(dbName)!; + } + + /** + * Get a database connection with priority support + */ + async getConnection(priority = 1): Promise { + const startTime = performance.now(); + + try { + // Check for available connection + const availableConnection = this.connections.find((conn) => !conn.inUse); + if (availableConnection) { + availableConnection.inUse = true; + availableConnection.lastUsed = Date.now(); + this.updateMetrics(startTime, true); + return availableConnection.db; + } + + // Create new connection if under limit + if (this.connections.length < this.maxConnections) { + const db = await this.createConnection(); + const connectionInfo: ConnectionInfo = { + db, + lastUsed: Date.now(), + inUse: true, + }; + this.connections.push(connectionInfo); + this.updateMetrics(startTime, true); + return db; + } + + // Queue the request + return new Promise((resolve, reject) => { + this.queue.push({ + resolve, + reject, + priority, + timestamp: Date.now(), + }); + + // Sort queue by priority (lower number = higher priority) + this.queue.sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + return a.timestamp - b.timestamp; // FIFO for same priority + }); + + this.metrics.queuedOperations += 1; + }); + } catch (error) { + this.updateMetrics(startTime, false); + throw error; + } + } + + /** + * Release a database connection back to the pool + */ + releaseConnection(db: Dexie): void { + const connectionInfo = this.connections.find((conn) => conn.db === db); + if (!connectionInfo) { + console.warn('Attempting to release unknown connection'); + return; + } + + connectionInfo.inUse = false; + connectionInfo.lastUsed = Date.now(); + + // Process queue if there are waiting operations + if (this.queue.length > 0) { + const nextOperation = this.queue.shift()!; + connectionInfo.inUse = true; + this.metrics.queuedOperations -= 1; + nextOperation.resolve(db); + } + } + + /** + * Execute a database operation with automatic connection management + */ + async execute( + operation: (db: Dexie) => Promise, + priority = 1 + ): Promise { + const db = await this.getConnection(priority); + try { + const result = await operation(db); + this.metrics.totalOperations += 1; + return result; + } catch (error) { + this.metrics.failedOperations += 1; + throw error; + } finally { + this.releaseConnection(db); + } + } + + /** + * Get current performance metrics + */ + getMetrics(): DBPerformanceMetrics { + return { + ...this.metrics, + connectionCount: this.connections.length, + activeConnections: this.connections.filter((conn) => conn.inUse).length, + }; + } + + /** + * Close all connections and cleanup + */ + async close() { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + } + + // Reject all queued operations + this.queue.forEach((op) => { + op.reject(new Error('Connection pool is closing')); + }); + this.queue.length = 0; + + // Close all connections + await Promise.all( + this.connections.map(async (conn) => { + try { + await conn.db.close(); + } catch (error) { + console.warn('Error closing database connection:', error); + } + }) + ); + + this.connections.length = 0; + DBConnectionPool.pools.delete(this.dbName); + } + + private async createConnection(): Promise { + const db = new Dexie(this.dbName); + db.version(this.dbConfig.version).stores(this.dbConfig.stores); + + // Add support for Chrome's Storage Buckets if available + if (this.supportsStorageBuckets()) { + try { + const bucket = await this.getStorageBucket(); + if (bucket) { + // Use storage bucket for better isolation + // Note: This would require Dexie to support storage buckets + // For now, we just log the availability + console.debug('Storage Buckets available for', this.dbName); + } + } catch (error) { + console.debug('Storage Buckets not available:', error); + } + } + + await db.open(); + return db; + } + + private supportsStorageBuckets(): boolean { + return 'navigator' in global && 'storageBuckets' in navigator; + } + + private async getStorageBucket() { + if (!this.supportsStorageBuckets()) return null; + + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Storage Buckets API is experimental + const buckets = navigator.storageBuckets; + return await buckets.open(this.dbName); + } catch (error) { + console.debug('Failed to open storage bucket:', error); + return null; + } + } + + private updateMetrics(startTime: number, success: boolean): void { + if (!this.enablePerformanceMonitoring) return; + + const duration = performance.now() - startTime; + this.metrics.averageConnectionTime = + (this.metrics.averageConnectionTime * this.metrics.totalOperations + + duration) / + (this.metrics.totalOperations + 1); + + if (!success) { + this.metrics.failedOperations += 1; + } + } + + private startCleanupTimer(): void { + this.cleanupTimer = setInterval(() => { + this.cleanupIdleConnections(); + }, this.idleTimeout); + } + + private cleanupIdleConnections(): void { + const now = Date.now(); + const connectionsToRemove: number[] = []; + + this.connections.forEach((conn, index) => { + if (!conn.inUse && now - conn.lastUsed > this.idleTimeout) { + connectionsToRemove.push(index); + } + }); + + // Remove idle connections (keep at least one if no queue) + const minConnections = this.queue.length > 0 ? 0 : 1; + const maxToRemove = this.connections.length - minConnections; + + connectionsToRemove.slice(0, maxToRemove).forEach((index) => { + const conn = this.connections[index]; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + conn.db.close().catch((error: Error) => { + console.warn('Error closing idle connection:', error); + }); + }); + + if (connectionsToRemove.length > 0) { + this.connections = this.connections.filter( + (_, index) => !connectionsToRemove.slice(0, maxToRemove).includes(index) + ); + } + } +} diff --git a/packages/logger/src/transports/index.ts b/packages/logger/src/transports/index.ts index 9fec258..0d15fb8 100644 --- a/packages/logger/src/transports/index.ts +++ b/packages/logger/src/transports/index.ts @@ -1,2 +1,3 @@ export * from './console'; export * from './storage'; +export * from './dbConnectionPool'; diff --git a/packages/logger/src/transports/storage.ts b/packages/logger/src/transports/storage.ts index ddbeed1..2774fc6 100644 --- a/packages/logger/src/transports/storage.ts +++ b/packages/logger/src/transports/storage.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ /* eslint-disable no-param-reassign */ /* eslint-disable consistent-return */ import { global } from '@ringcentral/mfe-shared'; @@ -12,14 +13,20 @@ import type { SerializedMessage, TransportInitOptions, } from '../interface'; +import { + DBConnectionPool, + type DBConnectionPoolOptions, + type DBPerformanceMetrics, +} from './dbConnectionPool'; const DEFAULT_BATCH_SIZE = 1024 * 512; // 512KB const DEFAULT_MAX_LOGS_SIZE = 1024 * 1024 * 100; // 100MB const DEFAULT_BATCH_TIMEOUT = 1000 * 60 * 5; // 5 minutes const DEFAULT_EXPIRED_TIME = 1000 * 60 * 60 * 24; // 1 day const DEFAULT_RECENT_TIME = 1000 * 60 * 60; // 1 hour +const DEFAULT_LAZY_INIT_DELAY = 2000; // 2 seconds delay for lazy initialization -interface Logs { +export interface Logs { /** * time of the first log in batched logs */ @@ -38,7 +45,13 @@ interface Logs { session?: string; } -interface StorageTransportOptions { +export enum LogPriority { + HIGH = 0, + NORMAL = 1, + LOW = 2, +} + +export interface StorageTransportOptions { /** * version of database */ @@ -75,6 +88,29 @@ interface StorageTransportOptions { * recent time for getting logs, default 1 hour */ recentTime?: number; + /** + * lazy initialization delay in milliseconds + */ + lazyInitDelay?: number; + /** + * connection pool options + */ + connectionPool?: DBConnectionPoolOptions; + /** + * transaction durability mode ('strict' | 'relaxed') + * relaxed mode provides better performance but less durability guarantee + */ + durabilityMode?: 'strict' | 'relaxed'; + /** + * enable performance monitoring + */ + enablePerformanceMonitoring?: boolean; + /** + * custom priority for different log levels + */ + logLevelPriority?: Partial< + Record<'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal', LogPriority> + >; } export type ExtraLogs = { @@ -88,81 +124,170 @@ export type ExtraLogs = { fileName: string; }[]; +interface QueuedWrite { + data: Logs; + priority: LogPriority; + timestamp: number; + resolve: () => void; + reject: (error: Error) => void; +} + export class StorageTransport implements ITransport { type = 'storage'; - protected _logs = new Map(); - - protected _table?: Dexie.Table; + protected _connectionPool?: DBConnectionPool; protected _session?: string; + protected _initialized = false; + + protected _initializing = false; + + protected _writeQueue: QueuedWrite[] = []; + + protected _isProcessingQueue = false; + + protected _data: Logs = { + time: Date.now(), + size: 0, + messages: [], + }; + + protected _timeout: NodeJS.Timeout | null = null; + + protected _lazyInitTimeout: NodeJS.Timeout | null = null; + + // Performance monitoring + protected _performanceMetrics = { + totalWrites: 0, + totalWriteTime: 0, + batchCount: 0, + queuedOperations: 0, + errors: 0, + }; + constructor(protected _options: StorageTransportOptions = {}) {} async init({ session }: TransportInitOptions) { this._session = session; this._data.session = session; - // TODO: refactor this with teardown - if (this._options.enabled) { - this._initDB().catch((err) => { - console.error('StorageTransport.initDB:', err); - }); - this._onUnload(); - if (global.localStorage) { - const key = `${this._tempKey}-prune-time`; - const PrunedTime = global.localStorage.getItem(key); - if (PrunedTime !== new Date().toLocaleDateString()) { - setTimeout(() => { - this._pruneLogs(); - global.localStorage.setItem(key, new Date().toLocaleDateString()); - // Avoid running during peak startup load. - // 5 seconds later - }, 5 * 1000); + + if (!this._options.enabled) return; + + // Implement lazy initialization to reduce startup I/O pressure + const lazyDelay = this._options.lazyInitDelay ?? DEFAULT_LAZY_INIT_DELAY; + + if (lazyDelay > 0) { + this._lazyInitTimeout = setTimeout(() => { + this._initializeDB().catch((err) => { + console.error('StorageTransport.lazyInit:', err); + }); + }, lazyDelay); + } else { + await this._initializeDB(); + } + + this._setupUnloadHandler(); + this._setupPeriodicCleanup(); + } + + protected async _initializeDB() { + if (this._initialized || this._initializing) return; + this._initializing = true; + + try { + // Initialize connection pool + this._connectionPool = DBConnectionPool.getInstance( + this.name, + { + version: this._options.version ?? 1, + stores: { logs: '&time, size' }, + }, + { + maxConnections: 2, // Conservative limit as recommended in the document + enablePerformanceMonitoring: + this._options.enablePerformanceMonitoring ?? false, + ...this._options.connectionPool, } - } - setInterval(() => { - this._pruneLogs(); - }, this.expiredTime); + ); + + this._initialized = true; + console.debug(`StorageTransport initialized for ${this.name}`); + } catch (error) { + console.error('Failed to initialize StorageTransport:', error); + this._performanceMetrics.errors++; + } finally { + this._initializing = false; } } - protected _onUnload() { - if (global.localStorage) { - const tempLogs = global.localStorage.getItem(this._tempKey); - if (tempLogs) { - global.localStorage.removeItem(this._tempKey); + protected _setupUnloadHandler() { + if (!global.localStorage) return; + + // Restore any temporary logs from previous session + const tempLogs = global.localStorage.getItem(this._tempKey); + if (tempLogs) { + global.localStorage.removeItem(this._tempKey); + try { const logs = JSON.parse(tempLogs) as Logs[]; logs.forEach((data) => { - this._saveLogs(data); + this._queueWrite(data, LogPriority.HIGH); // High priority for restored logs }); + } catch (error) { + console.warn('Failed to restore temporary logs:', error); } - window.addEventListener('beforeunload', () => { - this._saveTemp(); - }); } + + // Save current state on page unload + window.addEventListener('beforeunload', () => { + this._saveTemporaryState(); + }); + } + + protected _setupPeriodicCleanup() { + if (!global.localStorage) return; + + const key = `${this._tempKey}-prune-time`; + const lastPrunedDate = global.localStorage.getItem(key); + + if (lastPrunedDate !== new Date().toLocaleDateString()) { + // Delay cleanup to avoid startup I/O pressure + setTimeout(() => { + this._pruneLogs(LogPriority.LOW); + global.localStorage.setItem(key, new Date().toLocaleDateString()); + }, 10 * 1000); // 10 seconds delay + } + + // Set up periodic cleanup + setInterval(() => { + this._pruneLogs(LogPriority.LOW); + }, this.expiredTime); } - protected _saveTemp() { + protected _saveTemporaryState() { + if (!global.localStorage) return; + const saveData: Logs[] = []; - // unsaved current logs + + // Save current unsaved logs if (this._data.messages.length) { - saveData.push(this._data); - } - // saving logs - if (this._savingLogs.size) { - this._savingLogs.forEach((data) => { - saveData.push(data); - }); + saveData.push({ ...this._data }); } + + // Save queued writes + this._writeQueue.forEach(({ data }) => { + saveData.push(data); + }); + if (saveData.length) { - global.localStorage.setItem(this._tempKey, stringify(saveData)); + try { + global.localStorage.setItem(this._tempKey, stringify(saveData)); + } catch (error) { + console.warn('Failed to save temporary state:', error); + } } } - protected get _tempKey() { - return `${this.name}-temp`; - } - get options() { return this._options; } @@ -171,23 +296,10 @@ export class StorageTransport implements ITransport { return `${this._options.prefix ?? 'rc-mfe'}:log`; } - protected async _initDB() { - const db = new Dexie(this.name); - db.version(this._options.version ?? 1).stores({ - logs: '&time, size', - }); - this._table = db.table('logs'); - db.open().catch((err) => { - console.error(`StorageTransport.initDB:`, err); - }); + protected get _tempKey() { + return `${this.name}-temp`; } - protected _data: Logs = { - time: Date.now(), - size: 0, - messages: [], - }; - get batchSize() { return this._options.batchNumber ?? DEFAULT_BATCH_SIZE; } @@ -196,298 +308,391 @@ export class StorageTransport implements ITransport { return this._options.batchTimeout ?? DEFAULT_BATCH_TIMEOUT; } - protected _timeout: NodeJS.Timeout | null = null; + get expiredTime() { + return this._options.expired ?? DEFAULT_EXPIRED_TIME; + } - write({ payload }: SerializedMessage) { - if (this._options.enabled) { - const message = this._stringify(payload); - this._data.size += message.length; - this._data.messages.push(message); - if (this._data.size > this.batchSize) { - this._saveDB(); - } else if (!this._timeout) { - this._timeout = setTimeout(() => { - this._saveDB(); - }, this.batchTimeout); - } - } + get maxLogsSize() { + return this._options.maxLogsSize ?? DEFAULT_MAX_LOGS_SIZE; } - protected _stringify(payload: Message) { - if (this._options.stringify) { - return this._options.stringify(payload); + get recentTime() { + return this._options.recentTime ?? DEFAULT_RECENT_TIME; + } + + protected _getLogPriority(payload: Message): LogPriority { + const logLevel = getLogLevelName( + payload.context.logLevel as number + ).toLowerCase() as 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + + const customPriority = this._options.logLevelPriority?.[logLevel]; + if (customPriority !== undefined) { + return customPriority; + } + + // Default priority mapping + switch (logLevel) { + case 'error': + case 'fatal': + return LogPriority.HIGH; + case 'warn': + return LogPriority.NORMAL; + default: + return LogPriority.LOW; } - return `${new Date(payload.time).toISOString()}|${ - payload.sequence - } ${getLogLevelName(payload.context.logLevel as number)}: [${( - payload.context.namespace as string[] - ).join(':')}] MSG: ${payload.message}`; } - /** - * save memory logs to database and prune logs - */ - async saveDB() { - await this._saveDB(); - await this._pruneLogs(); + write({ payload }: SerializedMessage) { + if (!this._options.enabled) return; + + const message = this._stringify(payload); + const priority = this._getLogPriority(payload); + + this._data.size += message.length; + this._data.messages.push(message); + + // Trigger batch save based on size or timeout + if (this._data.size > this.batchSize) { + this._saveCurrentBatch(priority); + } else if (!this._timeout) { + this._timeout = setTimeout(() => { + this._saveCurrentBatch(priority); + }, this.batchTimeout); + } } - protected _saveDB() { + protected _saveCurrentBatch(priority: LogPriority) { clearTimeout(this._timeout!); this._timeout = null; - const data = this._data; + + const data = { ...this._data }; this._data = { time: Date.now(), size: 0, messages: [], session: this._session!, }; - if (!data.messages.length) return; - return this._saveLogs(data); + + if (data.messages.length === 0) return; + + this._queueWrite(data, priority); } - /** - * saving logs promise - */ - savingLogsPromise?: Promise; + protected _queueWrite(data: Logs, priority: LogPriority) { + return new Promise((resolve, reject) => { + this._writeQueue.push({ + data, + priority, + timestamp: Date.now(), + resolve, + reject, + }); + + // Sort queue by priority + this._writeQueue.sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + return a.timestamp - b.timestamp; + }); - protected _savingLogs = new Set(); + this._performanceMetrics.queuedOperations += 1; + this._processWriteQueue(); + }); + } - protected async _checkTimeKey(time: number): Promise { - const count = await this._table?.where('time').equals(time).count(); - if (count) { - return this._checkTimeKey(time + 1); + protected async _processWriteQueue() { + if (this._isProcessingQueue || this._writeQueue.length === 0) return; + if (!this._initialized) { + await this._initializeDB(); } - return time; - } + if (!this._connectionPool) return; - protected async _saveLogs(data: Logs) { - this._savingLogs.add(data); - data.time = await this._checkTimeKey(data.time); - const savingLogsPromise = this._table?.add(data).then(() => { - this._savingLogs.delete(data); - if (this.savingLogsPromise === savingLogsPromise) { - this.savingLogsPromise = undefined; - } - }); - if (this.savingLogsPromise) { - const _saveLogsPromise = this.savingLogsPromise; - const nextSaveLogsPromise = Promise.all([ - savingLogsPromise, - _saveLogsPromise, - ]).then(() => { - if (this.savingLogsPromise === nextSaveLogsPromise) { - this.savingLogsPromise = undefined; + this._isProcessingQueue = true; + + try { + while (this._writeQueue.length > 0) { + const write = this._writeQueue.shift()!; + this._performanceMetrics.queuedOperations -= 1; + + try { + await this._writeToDatabase(write.data, write.priority); + write.resolve(); + this._performanceMetrics.batchCount += 1; + } catch (error) { + write.reject(error as Error); + this._performanceMetrics.errors += 1; } - }); - this.savingLogsPromise = nextSaveLogsPromise; - return nextSaveLogsPromise; + } + } finally { + this._isProcessingQueue = false; } - this.savingLogsPromise = savingLogsPromise; - return savingLogsPromise; } - get expiredTime() { - return this._options.expired ?? DEFAULT_EXPIRED_TIME; - } + protected async _writeToDatabase( + data: Logs, + priority: LogPriority + ): Promise { + if (!this._connectionPool) throw new Error('Database not initialized'); - protected _deleteExpiredLogs() { - return this._deleteLogs(Date.now() - this.expiredTime); - } + const startTime = performance.now(); + + try { + await this._connectionPool.execute(async (db) => { + const table = db.table('logs'); + + // Ensure unique time key + data.time = await this._ensureUniqueTimeKey(table, data.time); + + // Use transaction with durability mode if supported + const transaction = db.transaction('rw', table, async () => { + await table.add(data); + }); - protected _deleteLogs(time: number) { - return this._table?.where('time').below(time).delete(); + // Set durability mode if supported (Chrome 121+) + if (this._options.durabilityMode && 'durability' in transaction) { + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - durability is experimental + transaction.durability = this._options.durabilityMode; + } catch (error) { + console.debug('Durability mode not supported:', error); + } + } + + await transaction; + }, priority); + + // Update performance metrics + if (this._options.enablePerformanceMonitoring) { + const duration = performance.now() - startTime; + this._performanceMetrics.totalWrites += 1; + this._performanceMetrics.totalWriteTime += duration; + } + } catch (error) { + console.error('Failed to write to database:', error); + throw error; + } } - protected async _getLogs() { - return this._table?.orderBy('time').toArray(); + protected async _ensureUniqueTimeKey( + table: Dexie.Table, + time: number + ): Promise { + const count = await table.where('time').equals(time).count(); + if (count > 0) { + return this._ensureUniqueTimeKey(table, time + 1); + } + return time; } - get maxLogsSize() { - return this._options.maxLogsSize ?? DEFAULT_MAX_LOGS_SIZE; + protected _stringify(payload: Message): string { + if (this._options.stringify) { + return this._options.stringify(payload); + } + return `${new Date(payload.time).toISOString()}|${ + payload.sequence + } ${getLogLevelName(payload.context.logLevel as number)}: [${( + payload.context.namespace as string[] + ).join(':')}] MSG: ${payload.message}`; } - // fast way to get total size of logs - protected async _getTotalSize() { - const sizes = - ((await this._table?.orderBy('size').keys()) as number[]) ?? []; - return sizes.reduce((acc, size) => { - return acc + size; - }, 0); + /** + * Force save current batch and prune logs + */ + async saveDB() { + this._saveCurrentBatch(LogPriority.NORMAL); + await this._processWriteQueue(); + await this._pruneLogs(LogPriority.NORMAL); } - protected async _pruneLogs() { - await this._deleteExpiredLogs(); - // only prune logs if total size is greater than maxLogsSize - const totalLogSize = await this._getTotalSize(); - if (totalLogSize > this.maxLogsSize) { - let sizeOverBy = this.maxLogsSize; - let cutoffTime = 0; - - await this._table - ?.orderBy('time') - .reverse() - .until((log: Logs) => { - sizeOverBy -= log.size; - if (sizeOverBy <= 0) { - cutoffTime = log.time; - return true; + protected async _pruneLogs(priority: LogPriority = LogPriority.LOW) { + if (!this._connectionPool) return; + + try { + await this._connectionPool.execute(async (db) => { + const table = db.table('logs'); + + // Delete expired logs + const expiredTime = Date.now() - this.expiredTime; + await table.where('time').below(expiredTime).delete(); + + // Prune by size if necessary + const allLogs = await table.orderBy('time').reverse().toArray(); + const totalSize = allLogs.reduce((sum, log) => sum + log.size, 0); + + if (totalSize > this.maxLogsSize) { + let cutoffTime = 0; + let sizeToKeep = this.maxLogsSize; + + for (const log of allLogs) { + sizeToKeep -= log.size; + if (sizeToKeep <= 0) { + cutoffTime = log.time; + break; + } } - return false; - }) - .toArray(); - if (cutoffTime > 0) { - await this._deleteLogs(cutoffTime); - } + if (cutoffTime > 0) { + await table.where('time').below(cutoffTime).delete(); + } + } + }, priority); + } catch (error) { + console.error('Failed to prune logs:', error); + this._performanceMetrics.errors += 1; } } - get recentTime() { - return this._options.recentTime ?? DEFAULT_RECENT_TIME; + /** + * Get performance metrics + */ + getPerformanceMetrics(): DBPerformanceMetrics & + typeof this._performanceMetrics { + const dbMetrics = this._connectionPool?.getMetrics() ?? { + connectionCount: 0, + activeConnections: 0, + averageConnectionTime: 0, + totalOperations: 0, + failedOperations: 0, + queuedOperations: 0, + }; + + return { + ...dbMetrics, + ...this._performanceMetrics, + averageWriteTime: + this._performanceMetrics.totalWrites > 0 + ? this._performanceMetrics.totalWriteTime / + this._performanceMetrics.totalWrites + : 0, + }; } /** - * query logs in the recent time + * Query logs in the recent time */ async queryLogs({ name: _name = this.name, recentTime = this.recentTime, extraLogs = [], }: { - /** - * name of the zip file - */ name?: string; - /** - * recent time of the logs - */ recentTime?: number; - /** - * extra logs - */ extraLogs?: ExtraLogs; } = {}) { - const allLogs = (await this._getLogs()) ?? []; - const allSessions = new Set(allLogs.map((log) => log.session)); - const data = - (await this._table - ?.where('time') + if (!this._connectionPool) { + await this._initializeDB(); + } + if (!this._connectionPool) return; + + return this._connectionPool.execute(async (db) => { + const table = db.table('logs'); + const allLogs = await table.orderBy('time').toArray(); + const allSessions = new Set(allLogs.map((log) => log.session)); + + const recentLogs = await table + .where('time') .above(Date.now() - recentTime) - .sortBy('time')) ?? []; - if (!data.length) return; - const endTime = new Date(data[data.length - 1].time).toISOString(); - const startTime = new Date(data[0].time).toISOString(); - const name = `${_name}_${startTime}_${endTime}`; - const logs = data.map((item) => item.messages.join('\n')).join('\n'); - const zip = new JSZip(); - const logFolder = zip.folder(name)!; - logFolder.file('recent.log', `${logs}\n`); - const historyFolder = logFolder.folder('history')!; - for (const session of allSessions) { - const _logs = allLogs - .filter((log) => log.session === session) + .sortBy('time'); + + if (!recentLogs.length) return; + + const endTime = new Date( + recentLogs[recentLogs.length - 1].time + ).toISOString(); + const startTime = new Date(recentLogs[0].time).toISOString(); + const name = `${_name}_${startTime}_${endTime}`; + const logs = recentLogs .map((item) => item.messages.join('\n')) .join('\n'); - historyFolder.file(`${session}.log`, `${_logs}\n`); - } - for (const extraLog of extraLogs) { - // Append a newline for string logs to ensure proper text formatting. - // Binary data is treated as raw data and does not require a newline. - if (typeof extraLog.log === 'string') { - zip.file(extraLog.fileName, `${extraLog.log}\n`); - } else { - zip.file(extraLog.fileName, extraLog.log); + + const zip = new JSZip(); + const logFolder = zip.folder(name)!; + logFolder.file('recent.log', `${logs}\n`); + + const historyFolder = logFolder.folder('history')!; + for (const session of allSessions) { + const sessionLogs = allLogs + .filter((log) => log.session === session) + .map((item) => item.messages.join('\n')) + .join('\n'); + historyFolder.file(`${session}.log`, `${sessionLogs}\n`); } - } - return { - name, - zip, - }; + + for (const extraLog of extraLogs) { + if (typeof extraLog.log === 'string') { + zip.file(extraLog.fileName, `${extraLog.log}\n`); + } else { + zip.file(extraLog.fileName, extraLog.log); + } + } + + return { name, zip }; + }, LogPriority.NORMAL); } /** - * get logs in the recent time + * Get logs in the recent time */ - async getLogs({ - name: _name = this.name, - recentTime = this.recentTime, - extraLogs = [], - }: { - /** - * name of the zip file - */ - name?: string; - /** - * recent time of the logs - */ - recentTime?: number; - /** - * extra logs - */ - extraLogs?: ExtraLogs; - } = {}) { - // before query logs, prune logs - await this._pruneLogs(); - const data = await this.queryLogs({ - name: _name, - recentTime, - extraLogs, - }); + async getLogs(options: Parameters[0] = {}) { + await this._pruneLogs(LogPriority.NORMAL); + const data = await this.queryLogs(options); if (!data) return; + const { zip, name } = data; const content = await this.zipLogs(zip); - return { - name, - content, - }; + return { name, content }; } /** - * zip logs + * Zip logs */ async zipLogs(zip: JSZip) { - const content = await zip.generateAsync({ + return zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 9, }, }); - return content; } /** - * download logs in the recent time + * Download logs */ - async downloadLogs({ - name = this.name, - recentTime = this.recentTime, - extraLogs = [], - }: { - /** - * name of the zip file - */ - name?: string; - /** - * recent time of the logs - */ - recentTime?: number; - /** - * extra logs - */ - extraLogs?: ExtraLogs; - } = {}) { + async downloadLogs(options: Parameters[0] = {}) { try { - // save current logs in memory - await this._saveDB(); - const data = await this.getLogs({ name, recentTime, extraLogs }); + await this.saveDB(); + const data = await this.getLogs(options); if (data) { await saveAs(data.content, `${data.name}.zip`); } } catch (error) { - console.error(`download log error`); + console.error('Download log error:', error); throw error; } } + + /** + * Cleanup resources + */ + async dispose() { + if (this._timeout) { + clearTimeout(this._timeout); + } + if (this._lazyInitTimeout) { + clearTimeout(this._lazyInitTimeout); + } + + // Process remaining queue + await this._processWriteQueue(); + + // Save temporary state + this._saveTemporaryState(); + + if (this._connectionPool) { + await this._connectionPool.close(); + } + } }