214 lines
7.1 KiB
JavaScript
214 lines
7.1 KiB
JavaScript
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<any>} - 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})(?<time>[m|d]{1})|\d{4}$/is);
|
|
if (m && m.length > 2) {
|
|
if (m.groups && m.groups['time']) {
|
|
const startUTC = moment().utc();
|
|
const daysOrMonths = m[1] > 0 ? m[1] : 6;
|
|
|
|
filter[fieldName] = {
|
|
$lte: startUTC.endOf('day').toDate(),
|
|
$gte: startUTC.subtract(daysOrMonths, m.groups['time'].toLowerCase() == 'd' ? 'days' : 'months').startOf('day').toDate(),
|
|
};
|
|
} else {
|
|
filter = { $expr: { $eq: [{ $year: `$${fieldName}` }, +m[0]] } };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return filter;
|
|
}
|
|
|
|
module.exports = {
|
|
getTranOps, runTransactionWithRetry, commitWithRetry, runWithRetry, runInTransaction,
|
|
getDateFilter
|
|
} |