diff --git a/lib/transaction.js b/lib/transaction.js index e80054b5b860..45dc51665877 100644 --- a/lib/transaction.js +++ b/lib/transaction.js @@ -153,6 +153,8 @@ export class Transaction { this.parent._afterCommitHooks.push(() => hook.apply(this, [this])); } } else { + this._handUpOpenSavepointHooks(); + for (const hook of this._afterCommitHooks) { await hook.apply(this, [this]); } @@ -161,6 +163,44 @@ export class Transaction { return result; } + /** + * Postgres commits a savepoint's work with the enclosing transaction whether or not the savepoint + * itself was ever committed, so the hooks of a savepoint still open when the root commits guard + * durable work and must run. Hand them up as though the savepoint had committed. Innermost first, + * so a nested savepoint's hooks reach an open parent before that parent hands its own up. A + * savepoint under one that rolled back is skipped: its work was discarded along with it. + * + * @private + */ + _handUpOpenSavepointHooks() { + for (const savepoint of this.savepoints.toReversed()) { + if (savepoint.finished || !savepoint._afterCommitHooks.length) { + continue; + } + + // The nearest ancestor still collecting hooks; a committed one has already handed its own up. + let target = savepoint.parent; + let discarded = false; + for (let ancestor = savepoint.parent; ancestor !== this; ancestor = ancestor.parent) { + if (ancestor.finished === 'rollback') { + discarded = true; + break; + } + if (target === ancestor && ancestor.finished) { + target = ancestor.parent; + } + } + + if (discarded) { + continue; + } + + for (const hook of savepoint._afterCommitHooks.splice(0)) { + target._afterCommitHooks.push(() => hook.apply(savepoint, [savepoint])); + } + } + } + /** * Rollback (abort) the transaction * @@ -273,6 +313,8 @@ export class Transaction { * On a nested transaction (a SAVEPOINT) the hook is deferred to the root transaction rather than run * when the savepoint itself commits, so it never observes work that is not yet durable. Rolling the * savepoint back — or any transaction it was handed up to — discards the hook without running it. + * A savepoint that is never committed or rolled back still has its work committed by the root, so + * its hooks run when the root commits. * * @param {Function} fn A callback function that is called with the committed transaction * @name afterCommit diff --git a/test/integration/cls.test.js b/test/integration/cls.test.js index a346d44b12e9..1bf3f055d1a9 100644 --- a/test/integration/cls.test.js +++ b/test/integration/cls.test.js @@ -467,6 +467,81 @@ for (const [implementation, createNamespace] of implementations) { expect(fired).to.deep.equal(['savepoint']); }); + it('runs hooks from an unmanaged savepoint that is never committed when the root commits', async () => { + const fired = []; + const root = await sequelize.transaction(); + const savepoint = await sequelize.transaction({ transaction: root }); + + savepoint.afterCommit((transaction) => fired.push(transaction)); + await root.commit(); + + expect(fired).to.have.lengthOf(1); + expect(fired[0]).to.equal(savepoint); + }); + + it('runs hooks from an unmanaged savepoint nested via CLS that is never committed', async () => { + const fired = []; + + await sequelize.transaction(async () => { + const savepoint = await sequelize.transaction(); + + savepoint.afterCommit(() => fired.push('savepoint')); + }); + + expect(fired).to.deep.equal(['savepoint']); + }); + + it('runs hooks from open savepoints nested in an open savepoint in registration order', async () => { + const fired = []; + const root = await sequelize.transaction(); + const outer = await sequelize.transaction({ transaction: root }); + const inner = await sequelize.transaction({ transaction: outer }); + + root.afterCommit(() => fired.push('root')); + outer.afterCommit(() => fired.push('outer')); + inner.afterCommit(() => fired.push('inner')); + await root.commit(); + + expect(fired).to.deep.equal(['root', 'outer', 'inner']); + }); + + it('runs hooks from an open savepoint nested in a savepoint that committed', async () => { + const fired = []; + const root = await sequelize.transaction(); + const outer = await sequelize.transaction({ transaction: root }); + const inner = await sequelize.transaction({ transaction: outer }); + + inner.afterCommit(() => fired.push('inner')); + await outer.commit(); + await root.commit(); + + expect(fired).to.deep.equal(['inner']); + }); + + it('drops hooks from an open savepoint nested in a savepoint that rolled back', async () => { + const fired = []; + const root = await sequelize.transaction(); + const outer = await sequelize.transaction({ transaction: root }); + const inner = await sequelize.transaction({ transaction: outer }); + + inner.afterCommit(() => fired.push('inner')); + await outer.rollback(); + await root.commit(); + + expect(fired).to.deep.equal([]); + }); + + it('drops hooks from an open savepoint when the root rolls back', async () => { + const fired = []; + const root = await sequelize.transaction(); + const savepoint = await sequelize.transaction({ transaction: root }); + + savepoint.afterCommit(() => fired.push('savepoint')); + await root.rollback(); + + expect(fired).to.deep.equal([]); + }); + it('calls a deferred hook with the transaction it was registered on', async () => { let received, savepoint;