const utils = require('./utils'), mongoose = require('mongoose'), moment = require('moment'), debug = require('debug')('agm:mongo_util'); function getTranOps(readLevel = 'snapshot') { return ({ readConcern: { level: readLevel }, writeConcern: { w: 'majority' } }); } /** * Runs the txnFunc and retries if TransientTransactionError encountered * @param {*} txnFunc a function which performs transaction within * @param {*} session the mongo connection's session */ async function runTransactionWithRetry(txnFunc, session) { if (!txnFunc && typeof (txnFunc) !== "function") throw new Error("invalid_func_param"); while (true) { try { await txnFunc(session); // performs transaction break; } catch (error) { // If transient error, retry the whole transaction if (error.hasOwnProperty("errorLabels") && error.errorLabels.includes("TransientTransactionError")) { debug("TransientTransactionError, retrying transaction ..."); continue; } else { throw error; } } } } /** * Retries commit if UnknownTransactionCommitResult encountered * @param {*} session the mongo connection's session */ async function commitWithRetry(session) { if (!session) return; while (true) { try { await session.commitTransaction(); // Uses write concern set at transaction start. break; } catch (error) { // Can retry commit if (error.hasOwnProperty("errorLabels") && error.errorLabels.includes("UnknownTransactionCommitResult")) { debug("UnknownTransactionCommitResult, retrying commit operation ..."); continue; } else { throw error; } } } } /** * Perform a function with retry when mongo "TransientTransactionError" error occurs * @param {*} fnc the function to run and retry * @param {*} maxRetries maximum retry times. 1-5 times. * @param {*} delaySecs delay, sleep time before trying again, 1 - 15 seconds. Default 5 seconds */ async function runWithRetry(fnc, maxRetries = 3, delaySecs = 5) { if (!func && typeof (func) !== "function") throw new Error("invalid_func_param"); const _delay = Math.min(Math.max(delaySecs, 1), 15); const _maxRetries = Math.min(Math.max(maxRetries, 1), 5); let tries = 0; while (tries < _maxRetries) { try { tries++; await fnc(); break; } catch (error) { // If transient error, retry after delay if (error.hasOwnProperty("errorLabels") && error.errorLabels.includes("TransientTransactionError")) { await utils.delay(_delay * 1000); continue; } else { throw error; } } } } /** * Runs a function within a MongoDB transaction, with proper session management * * @param {Function} func - The function to run within the transaction * @param {Object} ses - Optional existing session to use * @param {Boolean} endSesDone - Whether to end the session when done * @param {Object} options - Optional transaction options * @returns {Promise} - Result of the function */ async function runInTransaction(func, ses = null, endSesDone = true, options = null) { if (!func || typeof (func) !== "function") throw new Error("invalid_func_param"); // Use provided session or create a new one with options const tranOptions = options || getTranOps(); const session = ses || (await mongoose.startSession(tranOptions)); // Only start a transaction if it's not already active let startedNewTransaction = false; try { // Check if session is valid and has a startTransaction method if (!session || typeof session.startTransaction !== 'function') { throw new Error("Invalid session object"); } // Only start a transaction if we don't already have an active one if (!session.inTransaction()) { session.startTransaction(tranOptions); startedNewTransaction = true; } // Execute the function within the transaction const result = await func(session); // Only commit if we started this transaction if (startedNewTransaction) { await commitWithRetry(session); } return result; } catch (error) { // Only abort if we started this transaction and if the session is still valid if (startedNewTransaction && session && typeof session.abortTransaction === 'function') { try { await session.abortTransaction(); } catch (abortError) { debug("Error aborting transaction:", abortError); // Continue with original error } } throw error; } finally { // Only end the session if requested and if we created it (not provided) if (endSesDone && !ses && session && typeof session.endSession === 'function') { try { session.endSession(); } catch (endError) { debug("Error ending session:", endError); // Don't throw this error as it's in finally block } } } } /** * Get a range query object for mongodb given a byTime string and a field name * @param {*} byTime a string representing the time range, e.g. '365d' for 365 days or '3m' for 3 months or '2021' for the year 2021 * or an array of ISO 8601 date strings for a date range or a ISO 8601 date. E.g.: ?byTime[]=2022-01-01&byTime[]=2025-04-02 or ?byTime=2022-01-01 * @param {*} fieldName the name of the field in the mongodb document to filter by * @returns a range query object for mongodb */ function getDateFilter(byTime, fieldName) { let filter = {}; if (!byTime || !fieldName) return filter; if (Array.isArray(byTime)) { const _byTime = byTime.map((d) => moment.utc(d)); if (_byTime.length === 1 && _byTime[0].isValid()) { const startUTC = _byTime[0]; filter[fieldName] = { $gte: startUTC.startOf('day').toDate(), $lte: startUTC.endOf('day').toDate(), }; } else if (_byTime.length === 2 && _byTime[0].isValid() && _byTime[1].isValid()) { const byRange = _byTime[0].isAfter(_byTime[1]) ? _byTime.reverse() : _byTime; filter[fieldName] = { $gte: byRange[0].startOf('day').toDate(), $lte: byRange[1].endOf('day').toDate(), }; } } else if (typeof byTime === 'string') { if (byTime.length >= 10 && byTime.indexOf('-') > 0) { const _byTime = moment.utc(byTime); if (_byTime.isValid()) { filter[fieldName] = { $gte: _byTime.startOf('day').toDate(), $lte: _byTime.endOf('day').toDate(), }; } } else { const m = byTime.match(/^(\d{1,3})(?