UNPKG

mongostate

Version:

Data state machine. Support transaction in mongoose.

406 lines (377 loc) 13.5 kB
const mongoose = require('mongoose'); const Schema = mongoose.Schema; const ObjectId = mongoose.Types.ObjectId; const timestamp = require('mongoose-timestamp'); const debug = require('debug')('mongostate'); const Joi = require('joi'); const lockSchema = require('./lib/schemas/lock'); const transactionSchema = require('./lib/schemas/transaction'); const { errorTypes, states, operations } = require('./lib/constants'); const MError = require('./lib/MError'); if (!mongoose.plugins.some(plugin => plugin[0] === timestamp)) { transactionSchema.plugin(timestamp); lockSchema.plugin(timestamp); } const optionsSchema = Joi.object({ id: Joi.alternatives().try(Joi.string(), Joi.object().type(ObjectId)).allow(''), connection: Joi.object().required(), transactionCollectionName: Joi.string().default('transaction'), lockCollectionName: Joi.string().default('lock'), subStateCollectionPrefix: Joi.string().default('sub_state_'), biz: Joi.object() }).unknown(); class Transaction { /** * 验证参数,创建实例,绑定ID * @param options */ constructor (options) { const { value, error } = Joi.validate(options, optionsSchema); if (error) throw error; this._id = value.id || new ObjectId; this._options = value; this._usedModels = {}; } /** * 获取id * @returns {*} */ get id () { return this._id; } /** * 获取事务model * @constructor */ get TransactionModel () { const { connection, transactionCollectionName } = this._options; return connection.model(transactionCollectionName, transactionSchema); } /** * 获取锁model * @constructor */ get LockModel () { const { connection, lockCollectionName } = this._options; return connection.model(lockCollectionName, lockSchema); } /** * 锁定实体 * @param entity * @param Model * @returns {Promise.<void>} * @private */ async _lock (entity, Model) { if (!entity) throw new MError(`Entity [${Model.modelName}:${entity._id}] is not exists`, errorTypes.INVALID_ENTITY_STATE); try { /** * Try to find the lock, if not, try to create the lock. */ const lock = { transaction: this.id, model: Model.modelName, entity: entity._id }; if (!(await this.LockModel.findOne(lock))) { await this.LockModel.create(lock); } } catch (err) { if (err.name === 'MongoError' && err.code === 11000) { throw new MError(`Entity [${Model.modelName}:${entity.id}] is locked!`, errorTypes.ENTITY_LOCKED); } else throw err; } } /** * 初始化次态数据 * @param SSModel * @param doc * @returns {Promise.<doc>} * @private */ async _initSubStateData (SSModel, doc) { // Bind the transaction id to the sub-state doc and save it. doc.__t = this.id; return await SSModel.create(doc); } /** * 推入数据操作 * @param action * @returns {Promise.<void>} * @private */ async _pushAction (action) { await this.TransactionModel.findByIdAndUpdate(this.id, { $push: { actions: action } }); } /** * 查询绑定的事务数据 * @returns {Promise.<*|Promise|Query>} * @private */ async _findTransaction () { return await this.TransactionModel.findById(this.id); } async _addUsedModel (Model, SSModel) { const modelName = Model.modelName; if (!this._usedModels[modelName]) { this._usedModels[modelName] = { Model, SSModel }; await this.TransactionModel.findByIdAndUpdate(this.id, { $addToSet: { usedModelNames: modelName } }); } } forceUseModel (Model, SSModel) { const modelName = Model.modelName; if (!this._usedModels[modelName]) { this._usedModels[modelName] = { Model, SSModel }; } } async _initTransaction () { const TransactionModel = this.TransactionModel; let transaction = await TransactionModel.findById(this.id); if (!transaction) { transaction = await TransactionModel.create({ _id: this.id, biz: JSON.parse(JSON.stringify(this._options.biz || {})) }); } else { if ([states.CANCELLED, states.COMMITTED].includes(transaction.state)) { throw new MError(`The transaction [${transaction.id}] has [${transaction.state}].`, errorTypes.INVALID_TRANSACTION_STATE); } } return transaction; } async _findOneAndLock (Model, SSModel, [criteria = {}]) { if (!this._trying) throw new MError('Do not execute dangerous operation outside try method of transaction!', errorTypes.INVALID_OPERATION); const srcEntity = await Model.findOne(criteria); // Init transaction data if transaction is not exists. let transaction = await this._findTransaction(); if (!transaction) transaction = await this._initTransaction(); if (transaction.state !== states.PENDING) { throw new MError('Transaction is not pending!', errorTypes.INVALID_TRANSACTION_STATE); } await this._lock(srcEntity || { _id: (!criteria._id || criteria._id.constructor.name === 'Object') ? new ObjectId : criteria._id }, Model); // Record usedModel await this._addUsedModel(Model, SSModel); // Try to find the entity from the sub-state model, // if not, find it from src model and copy it to sub-state model. const entity = await SSModel.findOne(criteria); if (!entity) { if (srcEntity) { const doc = srcEntity.toJSON(); Reflect.deleteProperty(doc, '__v'); await this._initSubStateData(SSModel, doc); } return srcEntity; } return entity; } async _findById (Model, SSModel, [id]) { return await this._findOneAndLock(Model, SSModel, [{ _id: id }]); } async _findOneAndRemove (Model, SSModel, [query]) { const entity = await this._findOneAndLock(Model, SSModel, [query]); // Record the action for rollback. await this._pushAction({ operation: operations.REMOVE, model: Model.modelName, entity: entity._id }); await SSModel.findOneAndRemove(query); return entity; } async _findByIdAndRemove (Model, SSModel, [id]) { return await this._findOneAndRemove(Model, SSModel, [{ _id: id }]); } async _findOneAndUpdate (Model, SSModel, [query, doc, options]) { const entity = await this._findOneAndLock(Model, SSModel, [query]); if (!entity) return null; await this._pushAction({ operation: operations.UPDATE, model: Model.modelName, entity: entity._id, }); // $unset is not support, so covert it to $set; if (doc.$unset) { doc.$set = doc.$set || {}; Object.keys(doc.$unset).forEach(key => { doc.$set[key] = null; }); Reflect.deleteProperty(doc, '$unset'); } return await SSModel.findOneAndUpdate(query, doc, options); } async _findByIdAndUpdate (Model, SSModel, [id, doc, options]) { return await this._findOneAndUpdate(Model, SSModel, [{ _id: id }, doc, options]); } async _create (Model, SSModel, [doc]) { if (!doc._id) doc._id = new ObjectId; const entity = await this._findById(Model, SSModel, [doc._id]); if (entity) throw new MError(`Entity [${Model.modelName}:${doc._id}] has already exists!`, errorTypes.INVALID_ENTITY_STATE); await this._pushAction({ operation: operations.CREATE, model: Model.modelName, entity: doc._id, }); // if doc is an entity, covert it to json doc. if (doc.constructor.name === 'model') { doc = doc.toJSON(); } // Create the new doc in sub-state model. return await this._initSubStateData(SSModel, doc); } // Wrapper original Model to TModel to support transaction. use (Model, force = false) { const modelName = Model.modelName; let SSModel; const SSModelName = `${this._options.subStateCollectionPrefix}${modelName}`; // Try to get registered sub-state Model, or register it. try { SSModel = this._options.connection.model(SSModelName); } catch (err) { if (err.name === 'MissingSchemaError') { const schema = Model.schema; // Bind the transaction to the sub-state Model for searching it later. schema.add({ __t: { type: Schema.ObjectId, index: true }, }); SSModel = this._options.connection.model(SSModelName, schema); } else throw err; } if (!SSModel) throw new MError(`SSModel [${SSModelName}] has not registed!`, errorTypes.INTERNAL_ERROR); if (force) { this.forceUseModel(Model, SSModel); } return { create: async (...params) => { return await this._create(Model, SSModel, params); }, findOneAndUpdate: async (...params) => { return await this._findOneAndUpdate(Model, SSModel, params); }, findByIdAndUpdate: async (...params) => { return await this._findByIdAndUpdate.bind(this)(Model, SSModel, params); }, findByIdAndRemove: async (...params) => { return await this._findByIdAndRemove.bind(this)(Model, SSModel, params); }, findOneAndRemove: async (...params) => { return await this._findOneAndRemove.bind(this)(Model, SSModel, params); }, findOne: async (...params) => { return await this._findOneAndLock.bind(this)(Model, SSModel, params); }, findById: async (...params) => { return await this._findById.bind(this)(Model, SSModel, params); }, }; } async 'try' (wrapper = function * () { }) { if (this._tried) throw new MError('The transaction have tried, do not try it again!', errorTypes.INVALID_OPERATION); if (wrapper.constructor.name !== 'GeneratorFunction') throw new MError('wrapper should be a generator function.', errorTypes.INVALID_PARAMETER); let result; try { this._trying = true; result = await wrapper.bind(this)(); await this.commit(); this._trying = false; } catch (err) { await this.cancel(err); throw err; } this._tried = true; return result; } async commit () { const transaction = await this._findTransaction(); if (!transaction) return; if (transaction.state !== states.PENDING) throw new MError(`Expected the transaction [${this.id}] to be pending, but got ${transaction.state}`, errorTypes.INVALID_TRANSACTION_STATE); const entitiesActivated = []; for (let { model, entity, operation } of transaction.actions.reverse()) { if (entitiesActivated.includes(`${model}:${entity}`)) continue; entitiesActivated.push(`${model}:${entity}`); const { Model, SSModel } = this._usedModels[model] || {}; if (!Model) throw new MError(`Model ${model} has not used, please use the model first`, errorTypes.INVALID_OPERATION); const subStateEntity = await SSModel.findById(entity); let doc; if (subStateEntity) { doc = subStateEntity.toJSON(); Reflect.deleteProperty(doc, '__v'); Reflect.deleteProperty(doc, '__t'); } if (doc) { const prevEntity = await Model.findById(entity); if (prevEntity) { await Model.findByIdAndUpdate(entity, doc); } else { await Model.create(doc); } } else { await Model.findByIdAndRemove(entity); } } await this._clearSubStateData(); await this._unlock(); await this.TransactionModel.findByIdAndUpdate(this.id, { $set: { state: states.COMMITTED } }); debug(`Transaction [${this.id}] committed!`); } async cancel (error) { const transaction = await this._findTransaction(); if (!transaction) return; if (![states.PENDING, states.ROLLBACK].includes(transaction.state)) { throw new MError(`Expected the transaction [${this.id}] to be pending/rollback, but got ${transaction.state}`, errorTypes.INVALID_TRANSACTION_STATE); } await this._rollback(); await this._unlock(); await this.TransactionModel.findByIdAndUpdate(this.id, { $set: { state: states.CANCELLED }, error: { message: error.message, stack: error.stack } }); debug(`Transaction [${this.id}] cancelled!`); } async _rollback () { const transaction = await this._findTransaction(); if (![states.PENDING, states.ROLLBACK].includes(transaction.state)) { throw new MError(`Expected the transaction [${this.id}] to be pending/rollback, but got ${transaction.state}`, errorTypes.INVALID_TRANSACTION_STATE); } await this.TransactionModel.findByIdAndUpdate(this.id, { $set: { state: states.ROLLBACK } }); await this._clearSubStateData(); debug(`Transaction [${this.id}] rollback success!`); } async _clearSubStateData () { const usedModelNames = Object.keys(this._usedModels); const t = await this._findTransaction(); if (t.usedModelNames.some(modelName => !usedModelNames.includes(modelName))) { throw new MError(`${t.usedModelNames} should be used first!`, errorTypes.INVALID_OPERATION); } for (let modelName of usedModelNames) { const { SSModel } = this._usedModels[modelName]; await SSModel.remove({ __t: this.id }); } } async _unlock () { await this.LockModel.remove({ transaction: this.id }); } } module.exports = Transaction; module.exports.errorTypes = errorTypes; module.exports.states = states; module.exports.MError = MError;