@lpgroup/feathers-mongodb-hooks
Version:
Hooks for feathers-mongo.
315 lines (275 loc) • 9.78 kB
JavaScript
/* eslint-disable no-use-before-define */
/* eslint-disable no-underscore-dangle */
// Read more:
// https://docs.mongodb.com/manual/core/transactions-in-applications/#transactions-retry
// https://www.mongodb.com/blog/post/how-to-select--for-update-inside-mongodb-transactions
// http://mongodb.github.io/node-mongodb-native/3.6/api/ClientSession.html
import { log, executeSequentially } from "@lpgroup/utils";
import { ObjectId } from "mongodb";
import { nanoid } from "nanoid";
import { sleep } from "@lpgroup/feathers-utils";
import { TransactionAborted } from "./exceptions.js";
const { debug, error } = log("database");
// Number of ms to wait before retrying a writeConflict
const writeConflictBase = 50;
const writeConflictJitterMax = 100;
// Number of times to retry to resume a writeConflict.
const writeConflictRetries = 20;
const sessionOptions = {
// readPreference: { mode: 'primary' },
};
const transactionOptions = {
readPreference: "primary",
readConcern: { level: "snapshot" },
writeConcern: { w: "majority", wtimeout: 1 },
};
let mongoClient = null;
export function setClient(client) {
mongoClient = client;
}
export function getClient() {
return mongoClient;
}
let mongoDatabase = null;
export function setDatabase(database) {
mongoDatabase = database;
}
export function getDatabase() {
return mongoDatabase;
}
const sessions = {};
export async function startSession() {
const sessionId = nanoid();
const session = getClient().startSession(sessionOptions);
sessions[sessionId] = { id: sessionId, counter: 1, session };
return { sessionId, session };
}
export function reuseSession(sessionId) {
if (!hasSessionObject(sessionId)) {
error(`reuseSession: Mongo session doesn't exist ${sessionId}`);
throw new TransactionAborted("reuseSession: Mongo session doesn't exist", sessionId);
}
getSessionObject(sessionId).counter += 1;
}
function hasSessionObject(sessionId) {
return !!sessions[sessionId];
}
function getSessionObject(sessionId) {
if (!sessions[sessionId]) {
// Maybe a service that reused the sessionId failed and deleted the id.
throw new TransactionAborted("getSessionObject: Session doesn't exist", sessionId);
}
return sessions[sessionId];
}
export function getSession(sessionId) {
return getSessionObject(sessionId).session;
}
export function getSessionCounter(sessionId) {
return getSessionObject(sessionId).counter;
}
export async function endSession(params) {
if (hasSessionObject(params.sessionId)) {
const session = getSession(params.sessionId);
// Might be in transaction if endSession is called by errorSession hook
if (session.inTransaction()) {
await session.abortTransaction();
}
await session.endSession();
delete sessions[params.sessionId];
// eslint-disable-next-line no-param-reassign
delete params.sessionId;
// eslint-disable-next-line no-param-reassign
delete params.mongodb.session;
}
}
async function _commitWithRetry(session, retry = 1) {
try {
await session.commitTransaction();
} catch (err) {
if (err.hasErrorLabel("UnknownTransactionCommitResult") || err.hasErrorLabel("WriteConflict")) {
if (retry >= writeConflictRetries) throw err;
const timeout = _getExponentialTimeoutWithJitter(retry);
const counter = getCounter("commitRetry");
debug(
`UnknownTransactionCommitResult, retrying commit operation. retry: ${retry}:${counter}, timeout: ${timeout}`,
);
await sleep(timeout);
await _commitWithRetry(session, retry + 1);
} else {
error("Error during commit", err);
throw err;
}
}
}
async function ensureTransactionCompletion(session, maxRetryCount = 50) {
// When we are trying to split our operations into multiple transactions
// Sometimes we are getting an error that the earlier transaction is still in progress
// To avoid that, we ensure the earlier transaction has finished
let count = 0;
while (session.inTransaction()) {
if (count >= maxRetryCount) {
break;
}
// Adding a delay so that the transaction get be committed
// eslint-disable-next-line no-await-in-loop, no-promise-executor-return
await new Promise((r) => setTimeout(r, 100));
count += 1;
}
}
export async function endSessionAndCommitTransaction(context) {
const { params } = context;
getSessionObject(params.sessionId).counter -= 1;
if (getSessionObject(params.sessionId).counter === 0) {
try {
if (getSession(params.sessionId).inTransaction()) {
await _commitWithRetry(getSession(params.sessionId));
debugMsg("commitWithRetry", context);
}
ensureTransactionCompletion(getSession(params.sessionId));
} catch (err) {
error("abortTransaction", err);
await endSession(params);
throw err;
}
return endSession(params);
}
return undefined;
}
export async function startGetAndLockTransaction(context, collections, retry = 0) {
const { params } = context;
try {
debugMsg("startGetAndLockTransaction 1 ", context);
await _startTransaction(params.sessionId);
const fromQuery = _getFilterQueriesFromQuery(context, collections);
const fromModel = _getFilterQueriesFromModel(context);
await _lockDocument(context, "updateOne", params.sessionId, fromQuery);
const result = await _lockDocument(context, "findOneAndUpdate", params.sessionId, fromModel);
debugMsg("startGetAndLockTransaction 2 ", context);
if (result.length > 0) return result[0];
} catch (err) {
if (err.codeName === "WriteConflict") {
if (retry >= writeConflictRetries) throw err;
// eslint-disable-next-line no-param-reassign
retry += 1;
const timeout = _getExponentialTimeoutWithJitter(retry);
const counter = getCounter("writeConflict");
debugMsg(`Write conflict (lock) retry: ${retry}:${counter}, timeout: ${timeout}`, context);
await sleep(timeout);
await getSession(params.sessionId).abortTransaction();
return startGetAndLockTransaction(context, collections, retry);
}
throw err;
}
return {};
}
async function _startTransaction(sessionId) {
if (getSessionCounter(sessionId) === 1) {
if (getSession(sessionId).inTransaction()) {
error("Aborting transaction, should it really be started here?", sessionId);
await getSession(sessionId).abortTransaction();
}
}
if (!getSession(sessionId).inTransaction()) {
return getSession(sessionId).startTransaction(transactionOptions);
}
return undefined;
}
async function _lockDocument(context, operation, sessionId, filterQueries) {
const lockQuery = { $set: { myLock: new ObjectId() } };
return executeSequentially(filterQueries, async (v) => {
debug(
`${sessionId} _lockDocument filterQueries ${JSON.stringify(
filterQueries.map((f) => ({
collection: f?.collection?.collectionName,
filterQuery: f.filterQuery,
})),
)}`,
);
const session = getSession(sessionId);
const result = await v.collection[operation](v.filterQuery, lockQuery, {
session,
}).catch((err) => {
debugMsg(
`_lockDocument error ${JSON.stringify({
state: session?.transaction?.state,
active: session?.transaction?.isActive,
})}:`,
context,
);
throw err;
});
debugMsg(`_lockDocument transactionState: ${session?.transaction?.state}:`, context);
return result;
});
}
/**
* Build array with filterQueries from options.Model set in
* the feathersjs Service. ie. users.class.js
* Using context.id that is parsed by feathersjs from the uri.
*/
function _getFilterQueriesFromModel(context) {
if (context?.service?.options?.Model) {
const { options } = context.service;
return [
{
collection: options.Model,
filterQuery: { ...context.params.query, [options.id]: context.id },
},
];
}
return [];
}
/**
* Get filterQuery based on collection parameter on lock-data hook.
*
* @param {*} collections [
* {
* collection: "users", // Name of mongodb collection
* field: "_id", // Name of mongodb document field
* query: "userId" // Name of query field in Feathersjs.
* }]
*/
function _getFilterQueriesFromQuery(context, collections) {
if (collections && collections.length > 0) {
const { params } = context;
const options = context?.service?.options;
return collections.map((v) => {
const field = v.field || options.id || "_id";
const query = params.query[v.query] || context.id;
return {
collection: getDatabase().collection(v.collection),
filterQuery: { [field]: query },
};
});
}
return [];
}
export function debugMsg(prefix, context, suffix = "") {
const { sessionId } = context.params;
let counter = "NA";
if (hasSessionObject(sessionId)) counter = getSessionCounter(sessionId);
debug(`${sessionId} ${prefix}(${counter}) ${context.method} ${getUrl(context)} ${suffix} `);
}
function getUrl(context) {
let url = context.path || "";
if (context.params.query)
Object.entries(context.params.query).forEach((key, value) => {
url = url.replace(`:${key}`, value);
});
if (context.id) url += `/${context.id}`;
return url;
}
function _getExponentialTimeoutWithJitter(retry) {
const exp = getRandomInt(retry * 0.5) + retry * 0.5;
const exponent = 1.6 ** exp;
const rand = getRandomInt(writeConflictJitterMax * retry);
return Math.floor(writeConflictBase * exponent + rand);
}
function getRandomInt(max) {
return Math.random() * Math.floor(max);
}
const counters = { writeConflict: 0, commitRetry: 0 };
function getCounter(name) {
counters[name] += 1;
return counters[name];
}