Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions lib/transaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
Expand All @@ -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
*
Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions test/integration/cls.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading