agmission/Development/server/helpers/mongo.js

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
}