mongoose-transaction-plugin
Version:
A mongoose plugin for transaction-like semantics between multiple documents.
395 lines (339 loc) • 14.6 kB
text/typescript
import * as mongoose from 'mongoose';
import * as _ from 'lodash';
import * as Bluebird from 'bluebird';
import * as _debug from 'debug';
import * as events from 'events';
const debug = _debug('transaction');
const RETRYCOUNT = 5;
const RetryTimeTable = [197, 173, 181, 149, 202];
const TRANSACTION_KEEP_COMMITTED = (process.env.TRANSACTION_KEEP_COMMITTED === 'true' || false);
export interface IHistory {
// collection name
col: string;
// _id does not necessarily have to be ObjectId;
oid: any;
shardKeyName: string;
shardKey: any;
// insert, update, remove
op: 'insert' | 'remove' | 'update';
// update query string.
query: string;
}
export interface IRollback {
col: string;
oid: any;
shardKeyName: string;
shardKey: any;
}
export interface ITransaction extends mongoose.Document {
history: IHistory[];
rollback: IRollback[];
// init -> () -> pending -> committed
state: string;
id: string;
}
interface IParticipant {
op: 'insert' | 'remove' | 'update';
doc: mongoose.Document;
model?: any;
cond?: Object;
_id?: any;
}
export class Transaction extends events.EventEmitter {
public static TRANSACTION_EXPIRE_THRESHOLD = 60 * 1000;
private static model: mongoose.Model<ITransaction>;
private static connection: mongoose.Connection;
public static get getModel() { return Transaction.model; }
private transaction: ITransaction;
private participants: IParticipant[] = [];
public static initialize(connection: mongoose.Connection) {
if (this.model) return;
const historySchema = new mongoose.Schema({
col: { type: String, required: true },
oid: { type: mongoose.Schema.Types.Mixed, required: true },
shardKeyName: { type: String, required: true },
shardKey: { type: mongoose.Schema.Types.Mixed, required: true },
op: { type: String, required: true },
query: { type: String, required: true }
});
const rollbackSchmea = new mongoose.Schema({
col: { type: String, required: true },
oid: { type: mongoose.Schema.Types.Mixed, required: true },
shardKeyName: { type: String, required: true },
shardKey: { type: mongoose.Schema.Types.Mixed, required: true }
});
const transactionSchema = new mongoose.Schema({
history: [historySchema],
rollback: [rollbackSchmea],
state: { type: String, required: true, default: 'init', index: true }
});
this.connection = connection;
this.model = connection.model<ITransaction>('Transaction', transactionSchema);
}
public begin() {
return Bluebird.try(async () => {
if (!Transaction.model) throw new Error('Not initialized exception');
if (this.transaction) throw new Error('Transaction has already been started');
const transaction = new Transaction.model();
// TODO: should be fixed mongoose.d.ts
this.transaction = await transaction.save();
debug('transaction created: %o', this.transaction);
return this;
}).catch((e: Error) => {
// TODO we should handle every exception correctly in here,
// otherwise the uncaught exception will make the process down.
// Ref. http://bluebirdjs.com/docs/api/disposer.html#note-about-disposers-in-node
// ## Possible exceptions
// - Error('Not initialized exception')
// - Error('Transaction has already been started')
// - A potential error from transaction.save
if (e.message === 'Transaction has already been started') return;
throw e;
}).disposer((tx: this, promise) => {
if (promise.isFulfilled()) {
return tx.commit()
.catch(e => {
console.log('tx.commit failed', e);
});
}
return tx.cancel()
.catch(e => {
console.log('tx.cancel failed', e);
});
});
}
static scope<R>(doInTransactionScope: (t: Transaction) => Bluebird<R> | Promise<R>): Bluebird<R> | Promise<R> {
return Bluebird.using<Transaction, R>(new Transaction().begin(), doInTransactionScope);
}
public get _id(): mongoose.Types.ObjectId {
return this.transaction._id;
}
public async cancel(): Promise<void> {
if (!this.transaction) return;
if (this.transaction.state && this.transaction.state !== 'init') return;
try {
await Bluebird.each(this.participants, async (participant) => {
if (participant.op === 'insert')
return participant.doc.remove();
return participant.doc.update({$unset: {__t: ''}}).exec();
});
await this.transaction.remove();
} catch (e) {
debug('[warning] removing __t has been failed');
}
this.transaction = undefined;
this.participants = [];
}
private static async rollback(transaction: ITransaction): Promise<void> {
if (!transaction) return;
if (transaction.state && transaction.state !== 'init') return;
const rollbackHistories = transaction.rollback || [];
await Bluebird.each(rollbackHistories, async (history) => {
debug('find Rollback history collection: ', history.col, ' oid: ', history.oid);
const collection = this.connection.db.collection(history.col);
await collection.deleteOne({_id: history.oid, [history.shardKeyName]: history.shardKey});
});
await (new Transaction.getModel()).collection.deleteOne({_id: transaction._id});
}
private static async commitHistory(history: IHistory, tid: mongoose.Types.ObjectId): Promise<void> {
return Promise.resolve(this.connection.db.collection(history.col))
.then(async collection => {
if (history.op === 'remove')
return Transaction.commitHistoryRemove(history, collection);
return Transaction.commitHistoryUpdate(history, tid, collection);
})
.catch(err => {
debug(`transaction ${history.op} failed ${err.message}`);
throw new Error(err);
});
}
private static async commitHistoryRemove(history: IHistory, collection: any): Promise<void> {
return collection.deleteOne({_id: history.oid, [history.shardKeyName]: history.shardKey});
}
private static async commitHistoryUpdate(history: IHistory, tid: mongoose.Types.ObjectId, collection: any): Promise<void> {
let query = JSON.parse(history.query);
if (history.op === 'insert') {
query = _.omit(query, ['_id', '__t', history.shardKeyName]);
} else {
query['$unset'] = query['$unset'] || {};
query['$set'] = query['$set'] || {};
query['$set'] = _.omit(query['$set'], ['_id', '__t', history.shardKeyName]);
query['$unset']['__t'] = '';
}
if (query['$set'] != null && Object.keys(query['$set']).length === 0) {
query = _.omit(query, ['$set']);
}
return collection.update({_id: history.oid, [history.shardKeyName]: history.shardKey, __t : tid}, query, { w : 1 });
}
public static async recommit(transaction: ITransaction): Promise<void> {
const histories = transaction.history;
if (histories && histories.length === 0)
return Transaction.rollback(transaction);
try {
await Bluebird.each(histories, async (history) => {
debug('find history collection: ', history.col, ' oid: ', history.oid);
await Transaction.commitHistory(history, transaction._id);
});
debug('transaction recommited!');
if (!TRANSACTION_KEEP_COMMITTED) {
await transaction.remove();
} else {
transaction.state = 'committed';
await transaction.save();
}
} catch (err) {
// 하나라도 실패하면 pending 상태로 recommit 처리된다.
debug('Fails to save whole transactions but they will be saved', err);
throw err;
}
}
private static async validate(doc: any): Promise<void> {
return new Promise<void>((resolve, reject) => {
return doc.validate(err => err && reject(err) || resolve());
});
}
private static async makeHistory(participants: IParticipant[], transaction: ITransaction) {
await Bluebird.each(participants, async (participant) => {
// TODO: should be fixed mongoose.d.ts
await Transaction.validate(participant.doc);
debug('delta: %o', (<any>participant.doc).$__delta());
const shardKeyName = Transaction.getShardKey(participant.doc);
let query: string;
if (participant.op === 'update') {
query = JSON.stringify(((<any>participant.doc).$__delta() || [null, {}])[1]);
} else if (participant.op === 'remove') {
query = JSON.stringify({ _id: '', [shardKeyName]: '' });
} else if (participant.op === 'insert') {
query = JSON.stringify(participant.doc);
}
debug(`[makeHistory] op : ${participant.op} shardKey : ${shardKeyName} history.oid : %o query : ${JSON.stringify(query)}`, participant.doc._id);
transaction.history.push({
col: (<any>participant.doc).collection.name,
oid: participant.doc._id,
shardKeyName,
shardKey: participant.doc[shardKeyName],
op: participant.op,
query: query
});
debug(`histroy : %o`, transaction.history[transaction.history.length - 1]);
});
}
public async commit(): Promise<void> {
if (!this.transaction) return;
await Transaction.makeHistory(this.participants, this.transaction);
debug('history generated: %o', this.transaction.history);
this.transaction.state = 'pending';
debug('transaction: %o', this.transaction);
try {
this.transaction = await this.transaction.save();
} catch (err) {
this.transaction.state = 'init';
throw err;
}
debug('apply participants\' changes');
try {
await Bluebird.map(this.participants, async (participant) => {
debug('commit: [%s] %o', participant.op, participant.doc);
debug('delta: %o', (participant.doc as any).$__delta());
if (participant.op === 'remove') return participant.doc.remove();
if (participant.op === 'insert') participant.doc.isNew = false;
return participant.doc.save();
});
debug('transaction committed');
// TRANSACTION_KEEP_COMMITTED 값에 따라 Transaction Document를 지우거나 갱신한다.
if (!TRANSACTION_KEEP_COMMITTED) {
await this.transaction.remove();
} else {
this.transaction.state = 'committed';
await this.transaction.save();
}
} catch (err) {
// 하나라도 실패하면 pending 상태로 recommit 처리된다.
debug('Fails to save whole transactions but they will be saved', err);
}
this.transaction = undefined;
this.participants = [];
}
// 생성될 document를 transaction에 참가시킴
public async insertDoc(doc: mongoose.Document, retry: boolean = false) {
if (!this.transaction) throw new Error('Could not find any transaction');
doc['__t'] = this.transaction._id;
const shardKey = Transaction.getShardKey(doc);
if (_.isNil(doc[shardKey])) throw new Error(`${shardKey} value is required`);
debug('insertDoc : %o', doc);
await this.setRollbackHistory(doc);
try {
await doc.collection.insert(doc);
} catch (e) {
if (retry || e.code !== 11000) throw e; // E11000 duplicate key error
const existsDoc = await doc.collection.findOne({_id: doc._id, [shardKey]: doc[shardKey]});
if (!existsDoc || _.isNil(existsDoc.__t) || existsDoc.__t.toString() === this.transaction._id.toString()) throw e;
const oldTransaction = await this.transaction.collection.findOne({_id: existsDoc.__t});
if (!oldTransaction) throw e;
await Transaction.recommit(oldTransaction);
return this.insertDoc(doc, true);
}
this.participants.push({ op: 'insert', doc: doc });
}
// 삭제할 document를 transaction에 참가시킴
public removeDoc(doc: mongoose.Document) {
if (!this.transaction) throw new Error('Could not find any transaction');
const id: mongoose.Types.ObjectId = doc['__t'];
if (!id || id.toHexString() !== this.transaction.id) throw new Error('Already other locked');
this.participants.push({ op: 'remove', doc: doc });
}
public async findOne<T extends mongoose.Document>(model: mongoose.Model<T>, cond: Object, fields?: Object, options?: Object): Promise<T> {
if (!this.transaction) throw new Error('Could not find any transaction');
const withCond = _.find(this.participants, p => {
return p.model === model && JSON.stringify(cond) === JSON.stringify(p.cond);
});
if (withCond && withCond.doc) return withCond.doc as T;
if (!options) options = { retrycount: RETRYCOUNT };
if (options['retrycount'] === undefined) {
debug('set retrycount ', options, options['retrycount']);
options['retrycount'] = RETRYCOUNT;
}
const opt = _.cloneDeep(options || {});
opt['__t'] = this.transaction._id;
opt['tModel'] = Transaction.getModel;
opt['transaction'] = true;
debug('tModel before ', opt['tModel'].collection.name);
debug('attempt write lock', _.omit(opt, 'tModel'));
let doc: T;
try {
doc = await model.findOne(cond, fields, opt).exec();
} catch (err) {
debug('transaction err : retrycount is ', options['retrycount']);
if (err.message !== 'write lock' || options['retrycount'] === 0) throw err;
options['retrycount'] -= 1;
await Bluebird.delay(RetryTimeTable[Math.floor(Math.random() * RetryTimeTable.length)]);
return this.findOne(model, cond, fields, options);
}
if (!doc) return;
const withSameId: IParticipant = _.find(this.participants, p => {
return p.model === model && (p.doc._id.equals(doc._id) as boolean);
});
if (withSameId && withSameId.doc) return withSameId.doc as T;
this.participants.push({op: 'update', doc: doc, model: model, cond: cond});
return doc;
}
private async setRollbackHistory(doc: mongoose.Document) {
const shardKey = Transaction.getShardKey(doc);
const rollbackHistory: IRollback = {
col: doc.collection.name,
oid: doc._id,
shardKey: doc[shardKey],
shardKeyName: shardKey
};
await this.transaction.collection.updateOne({ _id: this.transaction._id },
{ $push : { rollback : rollbackHistory } }
);
this.transaction.rollback.push(rollbackHistory);
}
private static getShardKey(doc: mongoose.Document): string {
return (<any> doc.schema).options &&
(<any> doc.schema).options.shardKey &&
Object.keys((<any> doc.schema).options.shardKey)[0] ||
'_id';
}
}