'use strict'; /** * Enhanced MongoDB transaction implementation with robust error handling * for resolving the "session.endSession is not a function" error * * This file adds a drop-in replacement for the original mongo.js runInTransaction * function that properly handles session management and transaction errors. */ const mongoose = require('mongoose'), debug = require('debug')('agm:mongo_enhanced'); /** * Transaction options with settings that reduce the likelihood of transaction aborts */ const DEFAULT_TRANSACTION_OPTIONS = { readConcern: { level: 'majority' }, writeConcern: { w: 'majority' }, maxCommitTimeMS: 60000 // Increased from 10000 to 60000 (1 minute) }; /** * Retry configuration */ const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '5', 10); const RETRY_DELAY_MS = parseInt(process.env.RETRY_DELAY || '1000', 10); /** * Transaction retry helper with exponential backoff and jitter * @param {Function} action Function to execute with retry logic * @param {Number} maxRetries Maximum number of retries (default: 5) * @returns {Promise} Result of the action */ async function withTransactionRetry(action, maxRetries = MAX_RETRIES) { let retryCount = 0; while (true) { try { return await action(); } catch (error) { retryCount++; // Check if we should retry or just throw the error const isTransientError = error.errorLabels && (error.errorLabels.includes("TransientTransactionError") || error.errorLabels.includes("UnknownTransactionCommitResult")); const isTransactionAborted = error.message && ( error.message.includes("has been aborted") || error.message.includes("transaction aborted") || error.message.includes("Transaction has been aborted") || error.message.includes("Cannot use a session that has ended") || error.message.includes("Transaction") && error.message.includes("aborted") ); const isSessionError = error.message && (error.message.includes("session") || error.message.includes("transaction")); if ((isTransientError || isTransactionAborted || isSessionError) && retryCount <= maxRetries) { // Calculate delay with exponential backoff and jitter const baseDelay = RETRY_DELAY_MS * Math.pow(2, retryCount - 1); const jitter = Math.random() * 0.5 * baseDelay; // Up to 50% jitter const delay = baseDelay + jitter; debug(`Retrying transaction (attempt ${retryCount}/${maxRetries}) after ${Math.round(delay)}ms delay due to error: ${error.message}`); await new Promise(resolve => setTimeout(resolve, delay)); } else { // Throw detailed error for better debugging const errorDetail = { message: error.message, errorLabels: error.errorLabels, retryCount, stack: error.stack }; debug(`Transaction failed after ${retryCount} retries: ${JSON.stringify(errorDetail, null, 2)}`); throw error; } } } } /** * Safe MongoDB operation with error handling and retry * @param {Function} operation MongoDB operation to execute * @param {Object} session MongoDB session * @param {Number} maxRetries Maximum number of retries (default: MAX_RETRIES) * @returns {Promise} Result of the operation */ async function safeMongoOperation(operation, session, maxRetries = MAX_RETRIES) { return withTransactionRetry(async () => { try { return await operation(session); } catch (error) { // Add additional context to the error error.operation = operation.name || 'unknown'; error.sessionValid = !!(session && typeof session.abortTransaction === 'function'); throw error; } }, maxRetries); } /** * Enhanced version of runInTransaction that handles session management properly * and resolves the "session.endSession is not a function" error * * @param {Function} func Function to run within the transaction * @param {Object} options Transaction options (optional) * @returns {Promise} Result of the function */ async function enhancedRunInTransaction(func, options = null) { if (!func || typeof (func) !== "function") { throw new Error("invalid_func_param"); } const tranOptions = options || DEFAULT_TRANSACTION_OPTIONS; return withTransactionRetry(async () => { // Always create a new session for each transaction attempt const session = await mongoose.startSession(tranOptions); try { session.startTransaction(); const result = await func(session); await session.commitTransaction(); return result; } catch (error) { // Safely abort the transaction if (session && typeof session.abortTransaction === 'function' && session.inTransaction()) { try { await session.abortTransaction(); } catch (abortError) { debug(`Error aborting transaction: ${abortError.message}`); // Continue with original error } } throw error; } finally { // Always properly end the session if (session && typeof session.endSession === 'function') { try { session.endSession(); } catch (endError) { debug(`Error ending session: ${endError.message}`); // Don't throw this error as it's in finally block } } } }); } /** * Enhanced transaction runner that can work with existing sessions or create new ones * This is useful when you want to support both standalone transactions and nested operations * * @param {Function} func Function to run within the transaction * @param {Object} existingSession Existing MongoDB session (optional) * @param {Object} options Transaction options (optional) * @returns {Promise} Result of the function */ async function runWithSessionOrTransaction(func, existingSession = null, options = null) { if (!func || typeof (func) !== "function") { throw new Error("invalid_func_param"); } // If we have an existing session, use it directly (don't create nested transaction) if (existingSession) { debug('Using existing session for operation'); return await func(existingSession); } // No existing session, create a new transaction debug('Creating new transaction session for operation'); return await enhancedRunInTransaction(func, options); } module.exports = { enhancedRunInTransaction, runWithSessionOrTransaction, withTransactionRetry, safeMongoOperation, DEFAULT_TRANSACTION_OPTIONS };