mongopatch
Version:
MongoDB patching tool
430 lines (357 loc) • 10.4 kB
JavaScript
var fs = require('fs');
var util = require('util');
var path = require('path');
var async = require('async');
var streams = require('stream-wrapper');
var parallel = require('parallel-transform');
var speedometer = require('speedometer');
var stringify = require('json-stable-stringify');
var bson = new (require('bson').pure().BSON)();
var mongojs = require('mongojs');
var traverse = require('traverse');
var diff = require('./diff');
var DEFAULT_CONCURRENCY = 1;
var ATTEMPT_LIMIT = 5;
var JSON_STRINGIFY_SOURCE = fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'json-stable-stringify', 'index.js'), 'utf-8');
var COMPARE_TEMPLATE = fs.readFileSync(path.join(__dirname, 'compare.js.txt'), 'utf-8');
var bsonCopy = function(obj) {
return bson.deserialize(bson.serialize(obj));
};
var extend = function(dest, src) {
if(!src) {
return dest;
}
Object.keys(src).forEach(function(key) {
var v = src[key];
dest[key] = v === undefined ? dest[key] : v;
});
return dest;
};
var noopCallback = function(doc, callback) {
callback();
};
var applyAfterCallback = function(afterCallback, patch, callback) {
var update = bsonCopy({
before: patch.before,
after: patch.after,
modified: patch.modified,
diff: patch.diff
});
afterCallback(update, callback);
};
var applyUpdateUsingQuery = function(patch, callback) {
var query = bsonCopy(patch.query);
query._id = patch.before._id;
patch.collection.findAndModify({
query: query,
'new': true,
update: patch.modifier
}, function(err, after) {
// Ensure arity
callback(err, after);
});
};
var applyUpdateUsingWhere = function(patch, callback) {
var document = traverse(patch.before).map(function(value) {
if(value instanceof mongojs.ObjectId) {
this.update(value.toJSON(), true);
} else if(value instanceof Date) {
this.update(value.toJSON(), true);
} else if(value instanceof mongojs.NumberLong) {
this.update(value.toNumber(), true);
} else if(value instanceof mongojs.Timestamp) {
this.update(util.format('%s:%s', value.getLowBits(), value.getHighBits()), true);
}
});
document = JSON.stringify(stringify(document));
var where = util.format(COMPARE_TEMPLATE, JSON_STRINGIFY_SOURCE, document);
var query = { _id: patch.before._id };
query.$where = where;
patch.collection.findAndModify({
query: query,
'new': true,
update: patch.modifier
}, function(err, after) {
callback(err, after);
});
};
var applyUpdateUsingDocument = function(worker, patch, callback) {
if(patch.attempts === ATTEMPT_LIMIT) {
// In some cases a document doesn't match itself when used
// as a query (perhaps a bug). Try one last time using the slow
// where operator.
patch.attempts++;
return applyUpdateUsingWhere(patch, callback);
}
async.waterfall([
function(next) {
// Make sure if additional properties have been added to the root
// of the document we still satisfy the query.
var query = { $and: [bsonCopy(patch.before), bsonCopy(patch.query)] };
patch.collection.findAndModify({
query: query,
'new': true,
update: patch.modifier
}, next);
},
function(after, _, next) {
if(after) {
return callback(null, after);
}
var query = bsonCopy(patch.query);
query._id = patch.before._id;
patch.collection.findOne(query, next);
},
function(document, next) {
if(!document) {
// The document doesn't meet the criteria anymore
return callback(null, null);
}
patch.before = document;
patch.modifier = null;
worker(document, function(err, modifier) {
if(err) {
err.patch = patch;
}
next(err, modifier);
});
},
function(modifier) {
if(!modifier) {
return callback(null, null);
}
patch.modifier = modifier;
patch.attempts++;
applyUpdateUsingDocument(worker, patch, callback);
}
], callback);
};
var applyUpdateDummy = function(tmpCollection, patch, callback) {
var id = patch.before._id;
var after;
async.waterfall([
function(next) {
tmpCollection.save(patch.before, next);
},
function(savedDocument, _, next) {
tmpCollection.findAndModify({
query: { _id: id },
'new': true,
update: patch.modifier
}, next);
},
function(result, _, next) {
after = result;
tmpCollection.remove({ _id: id }, next);
},
function() {
callback(null, after);
}
], callback);
};
var loggedTransformStream = function(logCollection, options, fn) {
if(!fn) {
fn = options;
options = null;
}
options = extend({
afterCallback: noopCallback,
diffObject: false,
concurrency: DEFAULT_CONCURRENCY
}, options);
return parallel(options.concurrency, function(patch, callback) {
var logDocument;
async.waterfall([
function(next) {
logCollection.insert({
before: patch.before,
collection: patch.collection.toString(),
query: JSON.stringify(patch.query),
modifier: JSON.stringify(patch.modifier),
modified: false,
createdAt: new Date()
}, next);
},
function(result, _, next) {
logDocument = result;
patch.attempts = patch.attempts || 1;
fn(patch, next);
},
function(after, next) {
patch.skipped = !after;
patch.modified = false;
if(patch.skipped) {
return logCollection.update({ _id: logDocument._id }, { $set: { modified: false, skipped: true, attempts: patch.attempts } },
function(err) {
callback(err, patch);
}
);
}
patch.after = after;
patch.diff = diff.deep(patch.before, patch.after, { object: options.diffObject });
patch.modified = !!Object.keys(patch.diff).length;
logCollection.update(
{ _id: logDocument._id },
{ $set: { after: after, modified: patch.modified, skipped: false, diff: patch.diff, attempts: patch.attempts } },
next
);
},
function(_, next) {
applyAfterCallback(options.afterCallback, patch, next);
},
function() {
callback(null, patch);
}
], function(err) {
if(err) {
err.patch = patch;
}
if(err && logDocument) {
var documentError = {
message: err.message,
stack: err.stack
};
logCollection.update(
{ _id: logDocument._id },
{ $set: { error: documentError } },
function() {
callback(err);
}
);
return;
}
callback(err);
});
});
};
var transformStream = function(options, fn) {
if(!fn) {
fn = options;
options = null;
}
options = extend({
afterCallback: noopCallback,
diffObject: false,
concurrency: DEFAULT_CONCURRENCY
}, options);
return parallel(options.concurrency, function(patch, callback) {
async.waterfall([
function(next) {
patch.attempts = patch.attempts || 1;
fn(patch, next);
},
function(after, next) {
patch.skipped = !after;
patch.modified = false;
if(patch.skipped) {
return callback(null, patch);
}
patch.after = after;
patch.diff = diff.deep(patch.before, patch.after, { object: options.diffObject });
patch.modified = !!Object.keys(patch.diff).length;
applyAfterCallback(options.afterCallback, patch, next);
},
function() {
callback(null, patch);
}
], function(err) {
if(err) {
err.patch = patch;
}
callback(err);
});
});
};
var patchStream = function(collection, query, options, worker) {
if(!worker) {
worker = options;
options = null;
}
query = query || {};
options = extend({ concurrency: DEFAULT_CONCURRENCY }, options);
var patch = parallel(options.concurrency, function(document, callback) {
var clone = bsonCopy(document);
worker(document, function(err, modifier) {
if (err) {
err.patch = {
before: clone,
modifier: modifier
};
return callback(err);
}
if(!modifier) {
return callback();
}
callback(null, { modifier:modifier, before:clone, query:query, collection:collection });
});
});
collection
.find(query, {}, { timeout: false })
.sort({ _id: 1 })
.pipe(patch);
return patch;
};
var progressStream = function(total) {
var delta = {};
var count = 0;
var modified = 0;
var skipped = 0;
var started = Date.now();
var speed = speedometer(); // documents per second
return streams.transform({ objectMode: true }, function(patch, encoding, callback) {
count++;
modified += (patch.modified ? 1 : 0);
skipped += (patch.skipped ? 1 : 0);
var currentSpeed = speed(1);
var remaining = Math.max(total - count, 0);
var diffed = patch.skipped ? delta : diff(patch.before, patch.after, { accumulate: delta, group: true });
patch.progress = {
total: total,
count: count,
modified: modified,
skipped: skipped,
speed: currentSpeed,
remaining: remaining,
eta: Math.round(remaining / currentSpeed),
time: Math.round((Date.now() - started) / 1000),
percentage: (100 * count / total),
diff: bsonCopy(diffed)
};
callback(null, patch);
});
};
var updateUsingQueryStream = function(options) {
return transformStream(options, applyUpdateUsingQuery);
};
var updateUsingDocumentStream = function(worker, options) {
return transformStream(options, function(patch, callback) {
applyUpdateUsingDocument(worker, patch, callback);
});
};
var updateDummyStream = function(tmpCollection, options) {
return transformStream(options, function(patch, callback) {
applyUpdateDummy(tmpCollection, patch, callback);
});
};
var loggedUpdateUsingQueryStream = function(logCollection, options) {
return loggedTransformStream(logCollection, options, applyUpdateUsingQuery);
};
var loggedUpdateUsingDocumentStream = function(logCollection, worker, options) {
return loggedTransformStream(logCollection, options, function(patch, callback) {
applyUpdateUsingDocument(worker, patch, callback);
});
};
var loggedUpdateDummyStream = function(logCollection, tmpCollection, options) {
return loggedTransformStream(logCollection, options, function(patch, callback) {
applyUpdateDummy(tmpCollection, patch, callback);
});
};
exports.logged = {};
exports.patch = patchStream;
exports.progress = progressStream;
exports.updateUsingQuery = updateUsingQueryStream;
exports.updateUsingDocument = updateUsingDocumentStream;
exports.updateDummy = updateDummyStream;
exports.logged.updateUsingQuery = loggedUpdateUsingQueryStream;
exports.logged.updateUsingDocument = loggedUpdateUsingDocumentStream;
exports.logged.updateDummy = loggedUpdateDummyStream;