159 lines
5.4 KiB
JavaScript
159 lines
5.4 KiB
JavaScript
'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<any>} 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<any>} 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<any>} 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
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
enhancedRunInTransaction,
|
|
withTransactionRetry,
|
|
safeMongoOperation,
|
|
DEFAULT_TRANSACTION_OPTIONS
|
|
};
|