UNPKG

@andrew_l/mongo-transaction

Version:

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

465 lines (452 loc) 12.7 kB
import { defer, isPromise, logger, assert, catchError, asyncForEach, env, noop, isFunction, isEqual, has, deepDefaults, 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" ]); 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 = 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) { assert.ok(!this._active, "Cannot run while transaction active."); this.reset(); this._active = true; const [cbError, cbResult] = await withContext(() => { provideTransactionScope(this); return 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() { assert.ok(!this._active, "Cannot commit while transaction active."); assert.ok(!this.error, this.error); await asyncForEach( this.hooks.committed.byCursor, (h) => catchError(h.callback), { concurrency: 4 } ); this.reset(); this.clean(); } async rollback() { assert.ok(!this._active, "Cannot rollback while transaction active."); const error = await effectsCleanup(this); if (error) { return Promise.reject(error); } await asyncForEach( this.hooks.rollbacks.byCursor, (h) => catchError(h.callback), { concurrency: 4 } ); 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); } async function effectsApply(scope, reason = "no reason") { let error; const onComplete = (err) => void (error = err || error); await 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 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 catchError(effect.setup); 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; 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 catchError(effect.cleanup); 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; } 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 (!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 (!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 async function(...args) { const connection = 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 MongoTransactionError( "Transaction client-side timeout" ); let [transactionError, transactionResult] = await 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(noop); 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) { await scope.rollback(); return Promise.reject(transactionError); } await scope.commit(); return 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 async function(...args) { const scope = createTransactionScope(fn); await retryOnError( { beforeRetryCallback, shouldRetryBasedOnError, maxAttempts, maxRetriesNumber, delayFactor, delayMaxMs, delayMinMs }, async () => { await scope.run.apply(this, args); if (scope.error) { return Promise.reject(scope.error); } } )().catch(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; } export { onCommitted, onMongoSessionCommitted, onRollback, useMongoSession, useTransactionEffect, withMongoTransaction, withTransaction, withTransactionControlled }; //# sourceMappingURL=index.mjs.map