UNPKG

@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
'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