@andrew_l/mongo-transaction
Version:
Manages side effects in MongoDB transactions, rollback on failure and preventing duplicates on retries.
474 lines (460 loc) • 13.1 kB
JavaScript
'use strict';
const toolkit = require('@andrew_l/toolkit');
const context = require('@andrew_l/context');
const mongodb = require('mongodb');
const [injectMongoSession, provideMongoSession] = context.createContext("withMongoTransaction");
function useMongoSession() {
return context.hasInjectionContext() ? injectMongoSession(null) : null;
}
function onMongoSessionCommitted(...args) {
let session;
let fn;
if (args.length === 2) {
[session, fn] = args;
} else {
session = injectMongoSession();
fn = args[0];
}
const q = toolkit.defer();
const onEnded = () => {
if (!session.transaction.isCommitted) {
return q.resolve(void 0);
}
try {
const result = fn();
if (toolkit.isPromise(result)) {
result.then((r) => q.resolve(r)).catch(q.reject);
} else {
q.resolve(result);
}
} catch (err) {
q.reject(err);
}
};
session.once("ended", onEnded);
const cancel = () => {
session.off("ended", onEnded);
};
return {
promise: q.promise,
cancel
};
}
const [injectTransactionScope, provideTransactionScope] = context.createContext([
"withTransaction",
"withTransactionControlled",
"withMongoTransaction"
]);
class TransactionScope {
constructor(fn) {
this.fn = fn;
const scope = this;
this.run = function(...args) {
const self = this === scope ? void 0 : this;
return scope._run(self, ...args);
};
}
/**
* @internal
*/
log = toolkit.logger("TransactionScope");
/**
* @internal
* Indicates currently ran scope
*/
_active = false;
/**
* Last run error
*/
error;
/**
* Last run result
*/
result;
run;
/**
* @internal
*/
hooks = {
committed: { byCursor: [], cursor: 0 },
effects: { byCursor: [], cursor: 0 },
rollbacks: { byCursor: [], cursor: 0 }
};
get active() {
return this._active;
}
/**
* @internal
*/
async _run(self, ...args) {
toolkit.assert.ok(!this._active, "Cannot run while transaction active.");
this.reset();
this._active = true;
const [cbError, cbResult] = await context.withContext(() => {
provideTransactionScope(this);
return toolkit.catchError(this.fn.bind(self, ...args));
})();
if (cbError) {
this.error = cbError;
} else {
const applyError = await effectsApply(this, "post");
if (applyError) {
this.error = applyError;
}
this.result = cbResult;
}
this._active = false;
}
async commit() {
toolkit.assert.ok(!this._active, "Cannot commit while transaction active.");
toolkit.assert.ok(!this.error, this.error);
await toolkit.asyncForEach(
this.hooks.committed.byCursor,
(h) => toolkit.catchError(h.callback),
{ concurrency: 4 }
);
this.reset();
this.clean();
}
async rollback() {
toolkit.assert.ok(!this._active, "Cannot rollback while transaction active.");
const error = await effectsCleanup(this);
if (error) {
return Promise.reject(error);
}
await toolkit.asyncForEach(
this.hooks.rollbacks.byCursor,
(h) => toolkit.catchError(h.callback),
{ concurrency: 4 }
);
this.reset();
this.clean();
}
reset() {
toolkit.assert.ok(!this._active, "Cannot reset while transaction active.");
this.hooks.effects.cursor = 0;
this.hooks.committed.cursor = 0;
this.hooks.rollbacks.cursor = 0;
this.result = void 0;
this.error = void 0;
}
clean() {
toolkit.assert.ok(!this._active, "Cannot clean while transaction active.");
this.hooks.effects = { byCursor: [], cursor: 0 };
this.hooks.committed = { byCursor: [], cursor: 0 };
this.hooks.rollbacks = { byCursor: [], cursor: 0 };
}
}
function createTransactionScope(fn) {
return new TransactionScope(fn);
}
async function effectsApply(scope, reason = "no reason") {
let error;
const onComplete = (err) => void (error = err || error);
await toolkit.asyncForEach(
scope.hooks.effects.byCursor,
(effect) => applyEffect(scope, effect, reason).then(onComplete),
{
concurrency: 4
}
);
return error;
}
async function effectsCleanup(scope, reason = "no reason") {
let error;
const onComplete = (err) => void (error = err || error);
await toolkit.asyncForEach(
scope.hooks.effects.byCursor,
(effect) => cleanupEffect(scope, effect, reason).then(onComplete),
{
concurrency: 4
}
);
return error;
}
async function applyEffect(scope, effect, reason = "no reason") {
if (effect.cleanup) return;
scope.log.debug(
"Effect name = %s, flush = %s, apply by %s",
effect.name,
effect.flush,
reason
);
const [err, effectResult] = await toolkit.catchError(effect.setup);
if (err) {
!toolkit.env.isTest && scope.log.error(
"Effect name = %s, flush = %s apply error",
effect.name,
effect.flush,
err
);
return err;
}
effect.cleanup = toolkit.isFunction(effectResult) ? effectResult : toolkit.noop;
return;
}
async function cleanupEffect(scope, effect, reason = "no reason") {
if (!effect.cleanup) return;
scope.log.debug(
"Effect name = %s, flush = %s, cleanup by %s",
effect.name,
effect.flush,
reason
);
const [err] = await toolkit.catchError(effect.cleanup);
if (err) {
!toolkit.env.isTest && scope.log.error(
"Effect name = %s, flush = %s, cleanup error",
effect.name,
effect.flush,
err
);
return err;
}
effect.cleanup = void 0;
}
async function useTransactionEffect(setup, options = {}) {
const scope = injectTransactionScope();
const { cursor, byCursor } = scope.hooks.effects;
const effectConfig = byCursor[cursor] ?? {};
const flush = options?.flush || "pre";
const name = options?.name || `Effect #${cursor + 1}`;
const dependencies = options.dependencies ?? [];
const prevDependencies = effectConfig.dependencies;
byCursor[cursor] = {
...effectConfig,
dependencies,
flush,
name,
setup
};
if (dependencies && prevDependencies) {
if (!toolkit.isEqual(dependencies, prevDependencies)) {
scope.log.debug(
"Effect name = %s, flush = %s, caused by dependencies",
name,
flush,
{
prevDependencies,
newDependencies: dependencies
}
);
await scheduleEffect(scope, cursor);
}
} else {
scope.log.debug(
"Effect name = %s, flush = %s, caused by missing dependencies",
name,
flush
);
await scheduleEffect(scope, cursor);
}
scope.hooks.effects.cursor++;
}
async function scheduleEffect(scope, cursor) {
const { byCursor } = scope.hooks.effects;
const effect = byCursor[cursor];
const cleanupErr = await cleanupEffect(scope, effect, "schedule pre");
if (cleanupErr) {
return Promise.reject(cleanupErr);
}
if (effect.flush === "pre") {
const err = await applyEffect(scope, effect, "schedule pre");
if (err) {
return Promise.reject(err);
}
}
}
function onCommitted(callback, dependencies) {
const scope = injectTransactionScope();
const { cursor, byCursor } = scope.hooks.committed;
const config = byCursor[cursor];
if (dependencies && config?.dependencies) {
if (!toolkit.isEqual(dependencies, config.dependencies)) {
scope.log.debug("OnCommitted caused by dependencies", {
prevDependencies: config.dependencies,
newDependencies: dependencies,
cursor
});
byCursor[cursor] = { callback, dependencies };
}
} else {
scope.log.debug("OnCommitted caused by missing dependencies", { cursor });
byCursor[cursor] = { callback, dependencies };
}
scope.hooks.committed.cursor++;
return () => {
byCursor[cursor].callback = toolkit.noop;
};
}
function onRollback(callback, dependencies) {
const scope = injectTransactionScope();
const { cursor, byCursor } = scope.hooks.rollbacks;
const config = byCursor[cursor];
if (dependencies && config?.dependencies) {
if (!toolkit.isEqual(dependencies, config.dependencies)) {
scope.log.debug("OnCommitted caused by dependencies", {
prevDependencies: config.dependencies,
newDependencies: dependencies,
cursor
});
byCursor[cursor] = { callback, dependencies };
}
} else {
scope.log.debug("OnCommitted caused by missing dependencies", { cursor });
byCursor[cursor] = { callback, dependencies };
}
scope.hooks.committed.cursor++;
return () => {
byCursor[cursor].callback = toolkit.noop;
};
}
function isTransactionAborted(transaction) {
return transaction?.state === "TRANSACTION_ABORTED";
}
function isTransactionCommittedEmpty(transaction) {
return transaction?.state === "TRANSACTION_COMMITTED_EMPTY";
}
function isMongoClientLike(value) {
return toolkit.has(value, ["startSession"]) && toolkit.isFunction(value.startSession);
}
const DEF_SESSION_OPTIONS = Object.freeze({
defaultTransactionOptions: {
readPreference: "primary",
readConcern: { level: "local" },
writeConcern: { w: "majority" }
}
});
function withMongoTransaction(connectionOrOptions, maybeFn, maybeOptions) {
const {
connection: connectionValue,
fn,
sessionOptions = {},
timeoutMS
} = prepareOptions(connectionOrOptions, maybeFn, maybeOptions);
return async function(...args) {
const connection = toolkit.isFunction(connectionValue) ? await connectionValue() : connectionValue;
const session = await connection.startSession(
sessionOptions
);
const scope = createTransactionScope(function(...args2) {
provideMongoSession(session);
return fn.call(this, session, ...args2);
});
const timeoutAt = timeoutMS ? Date.now() + timeoutMS : 0;
const timeoutError = new mongodb.MongoTransactionError(
"Transaction client-side timeout"
);
let [transactionError, transactionResult] = await toolkit.catchError(
() => session.withTransaction(async () => {
if (timeoutAt && timeoutAt < Date.now()) {
return Promise.reject(timeoutError);
}
await scope.run.apply(this, args);
if (scope.error) {
return Promise.reject(scope.error);
}
})
);
const { result } = scope;
await session.endSession().catch(toolkit.noop);
if (transactionResult === void 0 && isTransactionCommittedEmpty(session.transaction)) ; else if (isTransactionAborted(session.transaction) && transactionResult === void 0 && transactionError === void 0) {
transactionError = new mongodb.MongoTransactionError(
"Transaction is explicitly aborted"
);
}
if (transactionError) {
await scope.rollback();
return Promise.reject(transactionError);
}
await scope.commit();
return result;
};
}
function prepareOptions(connectionOrOptions, maybeFn, maybeOptions) {
let options;
if (toolkit.isFunction(connectionOrOptions) || isMongoClientLike(connectionOrOptions)) {
options = {
...maybeOptions || {},
connection: connectionOrOptions,
fn: maybeFn
};
} else {
options = connectionOrOptions;
}
return toolkit.deepDefaults(options, {
sessionOptions: DEF_SESSION_OPTIONS
});
}
function withTransaction(fn, {
beforeRetryCallback,
shouldRetryBasedOnError = () => true,
maxAttempts,
maxRetriesNumber = 5,
delayFactor = 0,
delayMaxMs = 1e3,
delayMinMs = 100
} = {}) {
return async function(...args) {
const scope = createTransactionScope(fn);
await toolkit.retryOnError(
{
beforeRetryCallback,
shouldRetryBasedOnError,
maxAttempts,
maxRetriesNumber,
delayFactor,
delayMaxMs,
delayMinMs
},
async () => {
await scope.run.apply(this, args);
if (scope.error) {
return Promise.reject(scope.error);
}
}
)().catch(toolkit.noop);
const { error, result } = scope;
if (error) {
await scope.rollback();
return Promise.reject(error);
} else {
await scope.commit();
}
return result;
};
}
function withTransactionControlled(fn) {
const scope = createTransactionScope(fn);
const controlled = {
run(...args) {
const self = this === controlled ? void 0 : this;
return scope.run.apply(self, args);
},
commit() {
return scope.commit();
},
rollback() {
return scope.rollback();
},
get active() {
return scope.active;
},
get result() {
return scope.result;
},
get error() {
return scope.error;
}
};
return controlled;
}
exports.onCommitted = onCommitted;
exports.onMongoSessionCommitted = onMongoSessionCommitted;
exports.onRollback = onRollback;
exports.useMongoSession = useMongoSession;
exports.useTransactionEffect = useTransactionEffect;
exports.withMongoTransaction = withMongoTransaction;
exports.withTransaction = withTransaction;
exports.withTransactionControlled = withTransactionControlled;
//# sourceMappingURL=index.cjs.map