'use strict'; const chai = require('chai'); const expect = chai.expect; const Support = require('./support'); const dialect = Support.getTestDialect(); const { DataTypes, IsolationLevel, Op, QueryTypes, Transaction, TransactionType, } = require('@sequelize/core'); const sinon = require('sinon'); const current = Support.sequelize; const delay = require('delay'); const pSettle = require('p-settle'); describe(Support.getTestDialectTeaser('Transaction'), () => { if (!current.dialect.supports.transactions) { return; } beforeEach(function () { this.sinon = sinon.createSandbox(); }); afterEach(function () { this.sinon.restore(); }); describe('constructor', () => { it('stores options', function () { const transaction = new Transaction(this.sequelize); expect(transaction.options).to.be.an.instanceOf(Object); }); it('generates an identifier', function () { const transaction = new Transaction(this.sequelize); expect(transaction.id).to.exist; }); it('should call dialect specific generateTransactionId method', function () { const transaction = new Transaction(this.sequelize); expect(transaction.id).to.exist; if (dialect === 'mssql') { expect(transaction.id).to.have.lengthOf(20); } }); }); describe('commit', () => { it('is a commit method available', () => { expect(Transaction).to.respondTo('commit'); }); }); describe('rollback', () => { it('is a rollback method available', () => { expect(Transaction).to.respondTo('rollback'); }); }); describe('autoCallback', () => { it('supports automatically committing', async function () { await this.sequelize.transaction(async () => {}); }); it('supports automatically rolling back with a thrown error', async function () { let t; await expect( this.sequelize.transaction(transaction => { t = transaction; throw new Error('Yolo'); }), ).to.eventually.be.rejected; expect(t.finished).to.equal('rollback'); }); it('supports automatically rolling back with a rejection', async function () { let t; await expect( this.sequelize.transaction(async transaction => { t = transaction; throw new Error('Swag'); }), ).to.eventually.be.rejected; expect(t.finished).to.equal('rollback'); }); it('runs afterCommit & afterTransaction hooks when a transaction is committed', async function () { const afterCommit = sinon.spy(); const afterTransaction = sinon.spy(); const afterRollback = sinon.spy(); let transaction; await this.sequelize.transaction(t => { transaction = t; transaction.afterCommit(afterCommit); transaction.afterRollback(afterRollback); transaction.afterTransaction(afterTransaction); return this.sequelize.query('SELECT 1+1', { transaction, type: QueryTypes.SELECT }); }); expect(afterCommit).to.have.been.calledOnce; expect(afterCommit).to.have.been.calledWith(transaction); expect(afterTransaction).to.have.been.calledOnce; expect(afterTransaction).to.have.been.calledWith(transaction); expect(afterRollback).to.not.have.been.called; }); it('runs afterRollback & afterTransaction hooks when a transaction is rolled back', async function () { const afterCommit = sinon.spy(); const afterTransaction = sinon.spy(); const afterRollback = sinon.spy(); let transaction; try { await this.sequelize.transaction(t => { transaction = t; transaction.afterCommit(afterCommit); transaction.afterRollback(afterRollback); transaction.afterTransaction(afterTransaction); throw new Error('Rollback'); }); } catch { /* ignore */ } expect(afterRollback).to.have.been.calledOnce; expect(afterRollback).to.have.been.calledWith(transaction); expect(afterTransaction).to.have.been.calledOnce; expect(afterTransaction).to.have.been.calledWith(transaction); expect(afterCommit).to.not.have.been.called; }); it('does not run hooks when a transaction is rolled back from database', async function () { this.sinon .stub(this.sequelize.queryInterface, '_commitTransaction') .rejects(new Error('Oh no, an error!')); const hook = sinon.spy(); await expect( (async function () { await this.sequelize.transaction(transaction => { transaction.afterCommit(hook); }); })(), ).to.eventually.be.rejected; expect(hook).to.not.have.been.called; }); if (dialect === 'postgres') { // See #3689, #3726 and #6972 (https://github.com/sequelize/sequelize/pull/6972/files#diff-533eac602d424db379c3d72af5089e9345fd9d3bbe0a26344503c22a0a5764f7L75) it('does not try to rollback a transaction that failed upon committing with SERIALIZABLE isolation level (#3689)', async function () { // See https://wiki.postgresql.org/wiki/SSI const hook1 = sinon.spy(); const hook2 = sinon.spy(); const Dots = this.sequelize.define('dots', { color: DataTypes.STRING }); await Dots.sync({ force: true }); const initialData = [ { color: 'red' }, { color: 'green' }, { color: 'green' }, { color: 'red' }, { color: 'green' }, { color: 'red' }, { color: 'green' }, { color: 'green' }, { color: 'green' }, { color: 'red' }, { color: 'red' }, { color: 'red' }, { color: 'green' }, { color: 'red' }, { color: 'red' }, { color: 'red' }, { color: 'green' }, { color: 'red' }, ]; await Dots.bulkCreate(initialData); const isolationLevel = IsolationLevel.SERIALIZABLE; let firstTransactionGotNearCommit = false; let secondTransactionGotNearCommit = false; const firstTransaction = async () => { await this.sequelize.transaction({ isolationLevel }, async t => { t.afterCommit(hook1); await Dots.update( { color: 'red' }, { where: { color: 'green' }, transaction: t, }, ); await delay(1500); firstTransactionGotNearCommit = true; }); }; const secondTransaction = async () => { await delay(500); await this.sequelize.transaction({ isolationLevel }, async t => { t.afterCommit(hook2); await Dots.update( { color: 'green' }, { where: { color: 'red' }, transaction: t, }, ); // Sanity check - in this test we want this line to be reached before the // first transaction gets to commit expect(firstTransactionGotNearCommit).to.be.false; secondTransactionGotNearCommit = true; }); }; await expect( Promise.all([firstTransaction(), secondTransaction()]), ).to.eventually.be.rejectedWith( 'could not serialize access due to read/write dependencies among transactions', ); expect(firstTransactionGotNearCommit).to.be.true; expect(secondTransactionGotNearCommit).to.be.true; // Only the second transaction worked expect(await Dots.count({ where: { color: 'red' } })).to.equal(0); expect(await Dots.count({ where: { color: 'green' } })).to.equal(initialData.length); expect(hook1).to.not.have.been.called; expect(hook2).to.have.been.called; }); } }); it('does not allow queries after commit', async function () { const t = await this.sequelize.startUnmanagedTransaction(); await this.sequelize.query('SELECT 1+1', { transaction: t, raw: true }); await t.commit(); await expect(this.sequelize.query('SELECT 1+1', { transaction: t, raw: true })) .to.be.eventually.rejectedWith( Error, /commit has been called on this transaction\([^)]+\), you can no longer use it\. \(The rejected query is attached as the 'sql' property of this error\)/, ) .and.have.deep.property('sql') .that.equal('SELECT 1+1'); }); it('does not allow queries immediately after commit call', async function () { await expect( (async () => { const t = await this.sequelize.startUnmanagedTransaction(); await this.sequelize.query('SELECT 1+1', { transaction: t, raw: true }); await Promise.all([ expect(t.commit()).to.eventually.be.fulfilled, expect(this.sequelize.query('SELECT 1+1', { transaction: t, raw: true })) .to.be.eventually.rejectedWith( Error, /commit has been called on this transaction\([^)]+\), you can no longer use it\. \(The rejected query is attached as the 'sql' property of this error\)/, ) .and.have.deep.property('sql') .that.equal('SELECT 1+1'), ]); })(), ).to.be.eventually.fulfilled; }); it('does not allow queries after rollback', async function () { await expect( (async () => { const t = await this.sequelize.startUnmanagedTransaction(); await this.sequelize.query('SELECT 1+1', { transaction: t, raw: true }); await t.rollback(); return await this.sequelize.query('SELECT 1+1', { transaction: t, raw: true }); })(), ).to.eventually.be.rejected; }); it('should not rollback if connection was not acquired', async function () { this.sinon .stub(this.sequelize.dialect.connectionManager, 'connect') .returns(new Promise(() => {})); const transaction = new Transaction(this.sequelize); await expect(transaction.rollback()).to.eventually.be.rejectedWith( 'Transaction cannot be rolled back because it never started', ); }); it('does not allow queries immediately after rollback call', async function () { await expect( this.sequelize.startUnmanagedTransaction().then(async t => { await Promise.all([ expect(t.rollback()).to.eventually.be.fulfilled, expect(this.sequelize.query('SELECT 1+1', { transaction: t, raw: true })) .to.be.eventually.rejectedWith( Error, /rollback has been called on this transaction\([^)]+\), you can no longer use it\. \(The rejected query is attached as the 'sql' property of this error\)/, ) .and.have.deep.property('sql') .that.equal('SELECT 1+1'), ]); }), ).to.eventually.be.fulfilled; }); it('does not allow commits after commit', async function () { await expect( (async () => { const t = await this.sequelize.startUnmanagedTransaction(); await t.commit(); return await t.commit(); })(), ).to.be.rejectedWith( 'Transaction cannot be committed because it has been finished with state: commit', ); }); it('should run hooks if a non-auto callback transaction is committed', async function () { const hook = sinon.spy(); let transaction; await expect( (async () => { try { const t = await this.sequelize.startUnmanagedTransaction(); transaction = t; transaction.afterCommit(hook); await t.commit(); expect(hook).to.have.been.calledOnce; expect(hook).to.have.been.calledWith(t); } catch (error) { // Cleanup this transaction so other tests don't // fail due to an open transaction if (!transaction.finished) { await transaction.rollback(); throw error; } throw error; } })(), ).to.eventually.be.fulfilled; }); it('should not run hooks if a non-auto callback transaction is rolled back', async function () { const hook = sinon.spy(); await expect( (async () => { const t = await this.sequelize.startUnmanagedTransaction(); t.afterCommit(hook); await t.rollback(); expect(hook).to.not.have.been.called; })(), ).to.eventually.be.fulfilled; }); it('should not run hooks if a non-auto callback transaction is rolled back in database', async function () { const hook = sinon.spy(); this.sinon .stub(this.sequelize.queryInterface, '_commitTransaction') .rejects(new Error('Oh no, an error!')); await expect( (async function () { const t = await this.sequelize.startUnmanagedTransaction(); t.afterCommit(hook); await t.commit(); })(), ).to.eventually.be.rejected; expect(hook).to.not.have.been.called; }); it('should throw an error if null is passed to afterCommit', async function () { const hook = null; let transaction; await expect( (async () => { try { const t = await this.sequelize.startUnmanagedTransaction(); transaction = t; transaction.afterCommit(hook); return await t.commit(); } catch (error) { // Cleanup this transaction so other tests don't // fail due to an open transaction if (!transaction.finished) { await transaction.rollback(); throw error; } throw error; } })(), ).to.eventually.be.rejectedWith('"callback" must be a function'); }); it('should throw an error if undefined is passed to afterCommit', async function () { const hook = undefined; let transaction; await expect( (async () => { try { const t = await this.sequelize.startUnmanagedTransaction(); transaction = t; transaction.afterCommit(hook); return await t.commit(); } catch (error) { // Cleanup this transaction so other tests don't // fail due to an open transaction if (!transaction.finished) { await transaction.rollback(); throw error; } throw error; } })(), ).to.eventually.be.rejectedWith('"callback" must be a function'); }); it('should throw an error if an object is passed to afterCommit', async function () { const hook = {}; let transaction; await expect( (async () => { try { const t = await this.sequelize.startUnmanagedTransaction(); transaction = t; transaction.afterCommit(hook); return await t.commit(); } catch (error) { // Cleanup this transaction so other tests don't // fail due to an open transaction if (!transaction.finished) { await transaction.rollback(); throw error; } throw error; } })(), ).to.eventually.be.rejectedWith('"callback" must be a function'); }); it('does not allow commits after rollback', async function () { await expect( (async () => { const t = await this.sequelize.startUnmanagedTransaction(); await t.rollback(); return await t.commit(); })(), ).to.be.rejectedWith( 'Transaction cannot be committed because it has been finished with state: rollback', ); }); it('does not allow rollbacks after commit', async function () { await expect( (async () => { const t = await this.sequelize.startUnmanagedTransaction(); await t.commit(); return await t.rollback(); })(), ).to.be.rejectedWith( 'Transaction cannot be rolled back because it has been finished with state: commit', ); }); it('does not allow rollbacks after rollback', async function () { await expect( (async () => { const t = await this.sequelize.startUnmanagedTransaction(); await t.rollback(); return await t.rollback(); })(), ).to.be.rejectedWith( 'Transaction cannot be rolled back because it has been finished with state: rollback', ); }); it('works even if a transaction: null option is passed', async function () { this.sinon.spy(this.sequelize, 'queryRaw'); const t = await this.sequelize.startUnmanagedTransaction({ transaction: null, }); await t.commit(); if (this.sequelize.dialect.supports.connectionTransactionMethods) { expect(this.sequelize.queryRaw.callCount).to.equal(0); } else { expect(this.sequelize.queryRaw.callCount).to.be.greaterThan(0); for (let i = 0; i < this.sequelize.queryRaw.callCount; i++) { expect(this.sequelize.queryRaw.getCall(i).args[1].transaction).to.equal(t); } } }); it('works even if a transaction: undefined option is passed', async function () { this.sinon.spy(this.sequelize, 'queryRaw'); const t = await this.sequelize.startUnmanagedTransaction({ transaction: undefined, }); await t.commit(); if (this.sequelize.dialect.supports.connectionTransactionMethods) { expect(this.sequelize.queryRaw.callCount).to.equal(0); } else { expect(this.sequelize.queryRaw.callCount).to.be.greaterThan(0); for (let i = 0; i < this.sequelize.queryRaw.callCount; i++) { expect(this.sequelize.queryRaw.getCall(i).args[1].transaction).to.equal(t); } } }); if (['mysql', 'mariadb'].includes(dialect)) { describe('deadlock handling', () => { // Create the `Task` table and ensure it's initialized with 2 rows const getAndInitializeTaskModel = async sequelize => { const Task = sequelize.define('task', { id: { type: DataTypes.INTEGER, primaryKey: true, }, }); await sequelize.sync({ force: true }); await Task.create({ id: 0 }); await Task.create({ id: 1 }); return Task; }; // Lock the row with id of `from`, and then try to update the row // with id of `to` const update = async (sequelize, Task, from, to) => { await sequelize .transaction(async transaction => { try { try { await Task.findAll({ where: { id: { [Op.eq]: from } }, lock: transaction.LOCK.UPDATE, transaction, }); await delay(10); await Task.update( { id: to }, { where: { id: { [Op.ne]: to } }, lock: transaction.LOCK.UPDATE, transaction, }, ); } catch (error) { console.log(error.message); } await Task.create({ id: 2 }, { transaction }); } catch (error) { console.log(error.message); } throw new Error('Rollback!'); }) .catch(() => {}); }; it('should treat deadlocked transaction as rollback', async function () { const Task = await getAndInitializeTaskModel(this.sequelize); // This gets called twice simultaneously, and we expect at least one of the calls to encounter a // deadlock (which effectively rolls back the active transaction). // We only expect createTask() to insert rows if a transaction is active. If deadlocks are handled // properly, it should only execute a query if we're actually inside a real transaction. If it does // execute a query, we expect the newly-created rows to be destroyed when we forcibly rollback by // throwing an error. // tl;dr; This test is designed to ensure that this function never inserts and commits a new row. await Promise.all([update(this.sequelize, Task, 1, 0), update(this.sequelize, Task, 0, 1)]); const count = await Task.count(); // If we were actually inside a transaction when we called `Task.create({ id: 2 })`, no new rows should be added. expect(count).to.equal( 2, 'transactions were fully rolled-back, and no new rows were added', ); }); it('should release the connection for a deadlocked transaction (1/2)', async function () { const Task = await getAndInitializeTaskModel(this.sequelize); // 1 of 2 queries should deadlock and be rolled back by InnoDB this.sinon.spy(this.sequelize.pool, 'release'); await Promise.all([update(this.sequelize, Task, 1, 0), update(this.sequelize, Task, 0, 1)]); // Verify that both of the connections were released expect(this.sequelize.pool.release.callCount).to.equal(2); // Verify that a follow-up READ_COMMITTED works as expected. // For unknown reasons, we need to explicitly rollback on MariaDB, // even though the transaction should've automatically been rolled // back. // Otherwise, this READ_COMMITTED doesn't work as expected. const User = this.sequelize.define('user', { username: DataTypes.STRING, }); await this.sequelize.sync({ force: true }); await this.sequelize.transaction( { isolationLevel: IsolationLevel.READ_COMMITTED }, async transaction => { const users0 = await User.findAll({ transaction }); expect(users0).to.have.lengthOf(0); await User.create({ username: 'jan' }); // Create a User outside of the transaction const users = await User.findAll({ transaction }); expect(users).to.have.lengthOf(1); // We SHOULD see the created user inside the transaction }, ); }); // The following code is supposed to cause a deadlock in MariaDB & MySQL // but starting with MariaDB 10.5.15, this does not happen anymore. // See https://github.com/sequelize/sequelize/issues/14174 it.skip('should release the connection for a deadlocked transaction (2/2)', async function () { const verifyDeadlock = async () => { const User = this.sequelize.define( 'user', { username: DataTypes.STRING, awesome: DataTypes.BOOLEAN, }, { timestamps: false }, ); await this.sequelize.sync({ force: true }); const { id } = await User.create({ username: 'jan' }); // First, we start a transaction T1 and perform a SELECT with it using the `LOCK.SHARE` mode (setting a shared mode lock on the row). // This will cause other sessions to be able to read the row but not modify it. // So, if another transaction tries to update those same rows, it will wait until T1 commits (or rolls back). // https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html const t1 = await this.sequelize.startUnmanagedTransaction(); const t1Jan = await User.findByPk(id, { lock: t1.LOCK.SHARE, transaction: t1 }); // Then we start another transaction T2 and see that it can indeed read the same row. const t2 = await this.sequelize.startUnmanagedTransaction({ isolationLevel: IsolationLevel.READ_COMMITTED, }); const t2Jan = await User.findByPk(id, { transaction: t2 }); // Then, we want to see that an attempt to update that row from T2 will be queued until T1 commits. // However, before commiting T1 we will also perform an update via T1 on the same rows. // This should cause T2 to notice that it can't function anymore, so it detects a deadlock and automatically rolls itself back (and throws an error). // Meanwhile, T1 should still be ok. const executionOrder = []; const [t2AttemptData, t1AttemptData] = await pSettle([ (async () => { try { executionOrder.push('Begin attempt to update via T2'); await t2Jan.update({ awesome: false }, { transaction: t2 }); executionOrder.push('Done updating via T2'); // Shouldn't happen } catch (error) { executionOrder.push('Failed to update via T2'); throw error; } await delay(30); try { // We shouldn't reach this point, but if we do, let's at least commit the transaction // to avoid forever occupying one connection of the pool with a pending transaction. executionOrder.push('Attempting to commit T2'); await t2.commit(); executionOrder.push('Done committing T2'); } catch { executionOrder.push('Failed to commit T2'); } })(), (async () => { await delay(100); try { executionOrder.push('Begin attempt to update via T1'); await t1Jan.update({ awesome: true }, { transaction: t1 }); executionOrder.push('Done updating via T1'); } catch (error) { executionOrder.push('Failed to update via T1'); // Shouldn't happen throw error; } await delay(150); try { executionOrder.push('Attempting to commit T1'); await t1.commit(); executionOrder.push('Done committing T1'); } catch { executionOrder.push('Failed to commit T1'); // Shouldn't happen } })(), ]); expect(t1AttemptData.isFulfilled).to.eq( true, 'T1 is not fullfilled, but should have been', ); expect(t2AttemptData.isRejected).to.eq(true, 'T2 is not rejected, but should have been'); expect(t2AttemptData.reason.message).to.include( 'Deadlock found when trying to get lock; try restarting transaction', ); expect(t1.finished).to.equal('commit'); expect(t2.finished).to.equal('rollback'); const expectedExecutionOrder = [ 'Begin attempt to update via T2', 'Begin attempt to update via T1', // 100ms after 'Done updating via T1', // right after 'Failed to update via T2', // right after 'Attempting to commit T1', // 150ms after 'Done committing T1', // right after ]; // The order things happen in the database must be the one shown above. However, sometimes it can happen that // the calls in the JavaScript event loop that are communicating with the database do not match exactly this order. // In particular, it is possible that the JS event loop logs `'Failed to update via T2'` before logging `'Done updating via T1'`, // even though the database updated T1 first (and then rushed to declare a deadlock for T2). const anotherAcceptableExecutionOrderFromJSPerspective = [ 'Begin attempt to update via T2', 'Begin attempt to update via T1', // 100ms after 'Failed to update via T2', // right after 'Done updating via T1', // right after 'Attempting to commit T1', // 150ms after 'Done committing T1', // right after ]; const executionOrderOk = Support.isDeepEqualToOneOf(executionOrder, [ expectedExecutionOrder, anotherAcceptableExecutionOrderFromJSPerspective, ]); if (!executionOrderOk) { throw new Error(`Unexpected execution order: ${executionOrder.join(' > ')}`); } }; for (let i = 0; i < 3 * Support.getPoolMax(); i++) { await verifyDeadlock(); await delay(10); } }); }); } if (dialect === 'sqlite3') { it('provides persistent transactions', async () => { const sequelize = await Support.createMultiTransactionalTestSequelizeInstance(); Support.destroySequelizeAfterTest(sequelize); const User = sequelize.define('user', { username: DataTypes.STRING, awesome: DataTypes.BOOLEAN, }); const t1 = await sequelize.startUnmanagedTransaction(); await sequelize.sync({ transaction: t1 }); const t0 = t1; await User.create({}, { transaction: t0 }); await t0.commit(); const persistentTransaction = await sequelize.startUnmanagedTransaction(); const users = await User.findAll({ transaction: persistentTransaction }); expect(users.length).to.equal(1); await persistentTransaction.commit(); }); } if (current.dialect.supports.startTransaction.transactionType) { describe('transaction types', () => { it('should support default transaction type DEFERRED', async function () { const t = await this.sequelize.startUnmanagedTransaction({}); await t.rollback(); expect(t.options.transactionType).to.equal('DEFERRED'); }); for (const key of Object.keys(TransactionType)) { it(`should allow specification of ${key} type`, async function () { const t = await this.sequelize.startUnmanagedTransaction({ type: key, }); await t.rollback(); expect(t.options.transactionType).to.equal(TransactionType[key]); }); } }); } if (dialect === 'sqlite3') { it('automatically retries on SQLITE_BUSY failure', async function () { const sequelize = await Support.createSingleTransactionalTestSequelizeInstance( this.sequelize, ); const User = sequelize.define('User', { username: DataTypes.STRING }); await User.sync({ force: true }); const newTransactionFunc = async function () { const t = await sequelize.startUnmanagedTransaction({ type: TransactionType.EXCLUSIVE }); await User.create({}, { transaction: t }); return t.commit(); }; await Promise.all([newTransactionFunc(), newTransactionFunc()]); const users = await User.findAll(); expect(users.length).to.equal(2); }); it('fails with SQLITE_BUSY when retry.match is changed', async function () { const sequelize = await Support.createSingleTransactionalTestSequelizeInstance( this.sequelize, ); const User = sequelize.define('User', { id: { type: DataTypes.INTEGER, primaryKey: true }, username: DataTypes.STRING, }); await User.sync({ force: true }); const newTransactionFunc = async function () { const t = await sequelize.startUnmanagedTransaction({ type: TransactionType.EXCLUSIVE, retry: { match: ['NO_MATCH'] }, }); // introduce delay to force the busy state race condition to fail await delay(2000); await User.create({ id: null, username: `test ${t.id}` }, { transaction: t }); return t.commit(); }; await expect(Promise.all([newTransactionFunc(), newTransactionFunc()])).to.be.rejectedWith( 'SQLITE_BUSY: database is locked', ); }); } if (current.dialect.supports.lock) { describe('row locking', () => { it('supports for update', async function () { const User = this.sequelize.define('user', { username: DataTypes.STRING, awesome: DataTypes.BOOLEAN, }); const t1Spy = sinon.spy(); const t2Spy = sinon.spy(); await this.sequelize.sync({ force: true }); await User.create({ username: 'jan' }); const t1 = await this.sequelize.startUnmanagedTransaction(); const t1Jan = await User.findOne({ where: { username: 'jan', }, lock: t1.LOCK.UPDATE, transaction: t1, }); const t2 = await this.sequelize.startUnmanagedTransaction({ isolationLevel: IsolationLevel.READ_COMMITTED, }); await Promise.all([ (async () => { await User.findOne({ where: { username: 'jan', }, lock: t2.LOCK.UPDATE, transaction: t2, }); t2Spy(); await t2.commit(); expect(t2Spy).to.have.been.calledAfter(t1Spy); // Find should not succeed before t1 has committed })(), (async () => { await t1Jan.update( { awesome: true, }, { transaction: t1, }, ); t1Spy(); await delay(2000); return await t1.commit(); })(), ]); }); if (current.dialect.supports.skipLocked) { it('supports for update with skip locked', async function () { const User = this.sequelize.define('user', { username: DataTypes.STRING, awesome: DataTypes.BOOLEAN, }); await this.sequelize.sync({ force: true }); await Promise.all([User.create({ username: 'jan' }), User.create({ username: 'joe' })]); const t1 = await this.sequelize.startUnmanagedTransaction(); const results = await User.findAll({ limit: 1, lock: true, transaction: t1, }); const firstUserId = results[0].id; const t2 = await this.sequelize.startUnmanagedTransaction(); const secondResults = await User.findAll({ limit: 1, lock: true, skipLocked: true, transaction: t2, }); expect(secondResults[0].id).to.not.equal(firstUserId); await Promise.all([t1.commit(), t2.commit()]); }); } it('fail locking with outer joins', async function () { const User = this.sequelize.define('User', { username: DataTypes.STRING }); const Task = this.sequelize.define('Task', { title: DataTypes.STRING, active: DataTypes.BOOLEAN, }); User.belongsToMany(Task, { through: 'UserTasks' }); Task.belongsToMany(User, { through: 'UserTasks' }); await this.sequelize.sync({ force: true }); const [john, task1] = await Promise.all([ User.create({ username: 'John' }), Task.create({ title: 'Get rich', active: false }), ]); await john.setTasks([task1]); await this.sequelize.transaction(t1 => { if (current.dialect.supports.lockOuterJoinFailure) { return expect( User.findOne({ where: { username: 'John', }, include: [Task], lock: t1.LOCK.UPDATE, transaction: t1, }), ).to.be.rejectedWith( 'FOR UPDATE cannot be applied to the nullable side of an outer join', ); } return User.findOne({ where: { username: 'John', }, include: [Task], lock: t1.LOCK.UPDATE, transaction: t1, }); }); }); if (current.dialect.supports.lockOf) { it('supports for update of table', async function () { const User = this.sequelize.define( 'User', { username: DataTypes.STRING }, { tableName: 'Person' }, ); const Task = this.sequelize.define('Task', { title: DataTypes.STRING, active: DataTypes.BOOLEAN, }); User.belongsToMany(Task, { through: 'UserTasks' }); Task.belongsToMany(User, { through: 'UserTasks' }); await this.sequelize.sync({ force: true }); const [john, task1] = await Promise.all([ User.create({ username: 'John' }), Task.create({ title: 'Get rich', active: false }), Task.create({ title: 'Die trying', active: false }), ]); await john.setTasks([task1]); await this.sequelize.transaction(async t1 => { const t1John = await User.findOne({ where: { username: 'John', }, include: [Task], lock: { level: t1.LOCK.UPDATE, of: User, }, transaction: t1, }); // should not be blocked by the lock of the other transaction await this.sequelize.transaction(t2 => { return Task.update( { active: true, }, { where: { active: false, }, transaction: t2, }, ); }); return t1John.save({ transaction: t1, }); }); }); } if (current.dialect.supports.lockKey) { it('supports for key share', async function () { const User = this.sequelize.define('user', { username: DataTypes.STRING, awesome: DataTypes.BOOLEAN, }); const t1Spy = sinon.spy(); const t2Spy = sinon.spy(); await this.sequelize.sync({ force: true }); await User.create({ username: 'jan' }); const t1 = await this.sequelize.startUnmanagedTransaction(); const t1Jan = await User.findOne({ where: { username: 'jan', }, lock: t1.LOCK.NO_KEY_UPDATE, transaction: t1, }); const t2 = await this.sequelize.startUnmanagedTransaction(); await Promise.all([ (async () => { await User.findOne({ where: { username: 'jan', }, lock: t2.LOCK.KEY_SHARE, transaction: t2, }); t2Spy(); return await t2.commit(); })(), (async () => { await t1Jan.update( { awesome: true, }, { transaction: t1, }, ); await delay(2000); t1Spy(); expect(t1Spy).to.have.been.calledAfter(t2Spy); return await t1.commit(); })(), ]); }); } it('supports for share (i.e. `SELECT ... LOCK IN SHARE MODE`)', async function () { const verifySelectLockInShareMode = async () => { const User = this.sequelize.define( 'user', { username: DataTypes.STRING, awesome: DataTypes.BOOLEAN, }, { timestamps: false }, ); await this.sequelize.sync({ force: true }); const { id } = await User.create({ username: 'jan' }); // First, we start a transaction T1 and perform a SELECT with it using the `LOCK.SHARE` mode (setting a shared mode lock on the row). // This will cause other sessions to be able to read the row but not modify it. // So, if another transaction tries to update those same rows, it will wait until T1 commits (or rolls back). // https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html const t1 = await this.sequelize.startUnmanagedTransaction(); await User.findByPk(id, { lock: t1.LOCK.SHARE, transaction: t1 }); // Then we start another transaction T2 and see that it can indeed read the same row. const t2 = await this.sequelize.startUnmanagedTransaction({ isolationLevel: IsolationLevel.READ_COMMITTED, }); const t2Jan = await User.findByPk(id, { transaction: t2 }); // Then, we want to see that an attempt to update that row from T2 will be queued until T1 commits. const executionOrder = []; const [t2AttemptData, t1AttemptData] = await pSettle([ (async () => { try { executionOrder.push('Begin attempt to update via T2'); await t2Jan.update({ awesome: false }, { transaction: t2 }); executionOrder.push('Done updating via T2'); } catch (error) { executionOrder.push('Failed to update via T2'); // Shouldn't happen throw error; } await delay(30); try { executionOrder.push('Attempting to commit T2'); await t2.commit(); executionOrder.push('Done committing T2'); } catch { executionOrder.push('Failed to commit T2'); // Shouldn't happen } })(), (async () => { await delay(100); try { executionOrder.push('Begin attempt to read via T1'); await User.findAll({ transaction: t1 }); executionOrder.push('Done reading via T1'); } catch (error) { executionOrder.push('Failed to read via T1'); // Shouldn't happen throw error; } await delay(150); try { executionOrder.push('Attempting to commit T1'); await t1.commit(); executionOrder.push('Done committing T1'); } catch { executionOrder.push('Failed to commit T1'); // Shouldn't happen } })(), ]); expect(t1AttemptData.isFulfilled).to.be.true; expect(t2AttemptData.isFulfilled).to.be.true; expect(t1.finished).to.equal('commit'); expect(t2.finished).to.equal('commit'); const expectedExecutionOrder = [ 'Begin attempt to update via T2', 'Begin attempt to read via T1', // 100ms after 'Done reading via T1', // right after 'Attempting to commit T1', // 150ms after 'Done committing T1', // right after 'Done updating via T2', // right after 'Attempting to commit T2', // 30ms after 'Done committing T2', // right after ]; // The order things happen in the database must be the one shown above. However, sometimes it can happen that // the calls in the JavaScript event loop that are communicating with the database do not match exactly this order. // In particular, it is possible that the JS event loop logs `'Done updating via T2'` before logging `'Done committing T1'`, // even though the database committed T1 first (and then rushed to complete the pending update query from T2). const anotherAcceptableExecutionOrderFromJSPerspective = [ 'Begin attempt to update via T2', 'Begin attempt to read via T1', // 100ms after 'Done reading via T1', // right after 'Attempting to commit T1', // 150ms after 'Done updating via T2', // right after 'Done committing T1', // right after 'Attempting to commit T2', // 30ms after 'Done committing T2', // right after ]; const executionOrderOk = Support.isDeepEqualToOneOf(executionOrder, [ expectedExecutionOrder, anotherAcceptableExecutionOrderFromJSPerspective, ]); if (!executionOrderOk) { throw new Error(`Unexpected execution order: ${executionOrder.join(' > ')}`); } }; for (let i = 0; i < 3 * Support.getPoolMax(); i++) { await verifySelectLockInShareMode(); await delay(10); } }); }); } });