UNPKG

@andrew_l/mongo-transaction

Version:

Manages side effects in MongoDB transactions, rollback on failure and preventing duplicates on retries.

365 lines (364 loc) 12.1 kB
import { assert, asyncForEach, catchError, deepDefaults, defer, env, has, isEqual, isFunction, isPromise, logger, noop, retryOnError } from "@andrew_l/toolkit"; import { createContext, hasInjectionContext, withContext } from "@andrew_l/context"; import { MongoTransactionError } from "mongodb"; const [injectMongoSession, provideMongoSession] = createContext("withMongoTransaction"); function useMongoSession() { return 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 = defer(); const onEnded = () => { if (!session.transaction.isCommitted) return q.resolve(void 0); try { const result = fn(); if (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] = createContext([ "withTransaction", "withTransactionControlled", "withMongoTransaction" ]); var TransactionScope = class { fn; log = logger("TransactionScope"); _active = false; error; result; run; hooks = { committed: { byCursor: [], cursor: 0 }, effects: { byCursor: [], cursor: 0 }, rollbacks: { byCursor: [], cursor: 0 } }; constructor(fn) { this.fn = fn; const scope = this; this.run = function(...args) { const self = this === scope ? void 0 : this; return scope._run(self, ...args); }; } get active() { return this._active; } _run(self, ...args) { if (this._active) return Promise.reject(/* @__PURE__ */ new Error("Cannot commit while transaction active.")); this.reset(); this._active = true; return Promise.resolve().then(() => withContext(() => { provideTransactionScope(this); return catchError(() => this.fn.call(self, ...args)); })()).then(({ 0: cbError, 1: cbResult }) => { if (cbError) this.error = cbError; else return effectsApply(this, "post").then((applyError) => { if (applyError) this.error = applyError; this.result = cbResult; }); }).finally(() => { this._active = false; }); } commit() { if (this._active) return Promise.reject(/* @__PURE__ */ new Error("Cannot commit while transaction active.")); if (this.error) return Promise.reject(this.error); return asyncForEach(this.hooks.committed.byCursor, (h) => catchError(h.callback), { concurrency: 4 }).then(() => { this.reset(); this.clean(); }); } rollback() { if (this._active) return Promise.reject(/* @__PURE__ */ new Error("Cannot rollback while transaction active.")); return effectsCleanup(this).then((error) => { if (error) return Promise.reject(error); return asyncForEach(this.hooks.rollbacks.byCursor, (h) => catchError(h.callback), { concurrency: 4 }).then(() => { this.reset(); this.clean(); }); }); } reset() { 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() { 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); } function effectsApply(scope, reason = "no reason") { let error; const onComplete = (err) => void (error = err || error); return asyncForEach(scope.hooks.effects.byCursor, (effect) => applyEffect(scope, effect, reason).then(onComplete), { concurrency: 4 }).then(() => error); } function effectsCleanup(scope, reason = "no reason") { let error; const onComplete = (err) => void (error = err || error); return asyncForEach(scope.hooks.effects.byCursor, (effect) => cleanupEffect(scope, effect, reason).then(onComplete), { concurrency: 4 }).then(() => error); } function applyEffect(scope, effect, reason = "no reason") { if (effect.cleanup) return Promise.resolve(void 0); scope.log.debug("Effect name = %s, flush = %s, apply by %s", effect.name, effect.flush, reason); return Promise.resolve().then(() => catchError(effect.setup)).then(({ 0: err, 1: effectResult }) => { if (err) { !env.isTest && scope.log.error("Effect name = %s, flush = %s apply error", effect.name, effect.flush, err); return err; } effect.cleanup = isFunction(effectResult) ? effectResult : noop; }); } function cleanupEffect(scope, effect, reason = "no reason") { if (!effect.cleanup) return Promise.resolve(void 0); scope.log.debug("Effect name = %s, flush = %s, cleanup by %s", effect.name, effect.flush, reason); return Promise.resolve().then(() => catchError(effect.cleanup)).then(({ 0: err }) => { if (err) { !env.isTest && scope.log.error("Effect name = %s, flush = %s, cleanup error", effect.name, effect.flush, err); return err; } effect.cleanup = void 0; }); } 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 (!isEqual(dependencies, prevDependencies)) { scope.log.debug("Effect name = %s, flush = %s, caused by dependencies", name, flush, { prevDependencies, newDependencies: dependencies }); return scheduleEffect(scope, cursor).then(() => { scope.hooks.effects.cursor++; }); } } else { scope.log.debug("Effect name = %s, flush = %s, caused by missing dependencies", name, flush); return scheduleEffect(scope, cursor).then(() => { scope.hooks.effects.cursor++; }); } return Promise.resolve(); } function scheduleEffect(scope, cursor) { const { byCursor } = scope.hooks.effects; const effect = byCursor[cursor]; return cleanupEffect(scope, effect, "schedule pre").then((cleanupErr) => { if (cleanupErr) return Promise.reject(cleanupErr); if (effect.flush === "pre") return applyEffect(scope, effect, "schedule pre").then((err) => { 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 (!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 = noop; }; } function onRollback(callback, dependencies) { const scope = injectTransactionScope(); const { cursor, byCursor } = scope.hooks.rollbacks; const config = byCursor[cursor]; if (dependencies && config?.dependencies) { if (!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 = noop; }; } function isTransactionAborted(transaction) { return transaction?.state === "TRANSACTION_ABORTED"; } function isTransactionCommittedEmpty(transaction) { return transaction?.state === "TRANSACTION_COMMITTED_EMPTY"; } function isMongoClientLike(value) { return has(value, ["startSession"]) && 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 function(...args) { return Promise.resolve().then(() => isFunction(connectionValue) ? connectionValue() : connectionValue).then((connection) => connection.startSession(sessionOptions)).then((session) => { const scope = createTransactionScope(function(...args) { provideMongoSession(session); return fn.call(this, session, ...args); }); const timeoutAt = timeoutMS ? Date.now() + timeoutMS : 0; const timeoutError = new MongoTransactionError("Transaction client-side timeout"); return catchError(() => session.withTransaction(() => { if (timeoutAt && timeoutAt < Date.now()) return Promise.reject(timeoutError); return Promise.resolve().then(() => scope.run.apply(this, args)).then(() => { if (scope.error) return Promise.reject(scope.error); }); })).then(({ 0: transactionError, 1: transactionResult }) => { const { result } = scope; return session.endSession().catch(noop).then(() => { if (transactionResult === void 0 && isTransactionCommittedEmpty(session.transaction)) {} else if (isTransactionAborted(session.transaction) && transactionResult === void 0 && transactionError === void 0) transactionError = new MongoTransactionError("Transaction is explicitly aborted"); if (transactionError) return scope.rollback().then(() => Promise.reject(transactionError)); return scope.commit().then(() => result); }); }); }); }; } function prepareOptions(connectionOrOptions, maybeFn, maybeOptions) { let options; if (isFunction(connectionOrOptions) || isMongoClientLike(connectionOrOptions)) options = { ...maybeOptions || {}, connection: connectionOrOptions, fn: maybeFn }; else options = connectionOrOptions; return deepDefaults(options, { sessionOptions: DEF_SESSION_OPTIONS }); } function withTransaction(fn, { beforeRetryCallback, shouldRetryBasedOnError = () => true, maxAttempts, maxRetriesNumber = 5, delayFactor = 0, delayMaxMs = 1e3, delayMinMs = 100 } = {}) { return function(...args) { const scope = createTransactionScope(fn); return retryOnError({ beforeRetryCallback, shouldRetryBasedOnError, maxAttempts, maxRetriesNumber, delayFactor, delayMaxMs, delayMinMs }, () => { return Promise.resolve().then(() => scope.run.apply(this, args)).then(() => { if (scope.error) return Promise.reject(scope.error); }); })().catch(noop).then(() => { const { error, result } = scope; if (error) return scope.rollback().then(() => Promise.reject(error)); return scope.commit().then(() => 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; } export { onCommitted, onMongoSessionCommitted, onRollback, useMongoSession, useTransactionEffect, withMongoTransaction, withTransaction, withTransactionControlled }; //# sourceMappingURL=index.mjs.map