Skip to content
This repository was archived by the owner on Apr 2, 2024. It is now read-only.

Commit 70b8815

Browse files
authored
Merge pull request share#259 from share/otapply-rollback
Catch synchronous errors in `Doc._otApply`
2 parents b46cc6d + 84a70af commit 70b8815

File tree

3 files changed

+171
-28
lines changed

3 files changed

+171
-28
lines changed

lib/client/doc.js

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,11 @@ Doc.prototype._handleOp = function(err, message) {
327327
}
328328

329329
this.version++;
330-
this._otApply(message, false);
331-
return;
330+
try {
331+
this._otApply(message, false);
332+
} catch (error) {
333+
return this._hardRollback(error);
334+
}
332335
};
333336

334337
// Called whenever (you guessed it!) the connection state changes. This will
@@ -512,8 +515,8 @@ function transformX(client, server) {
512515
Doc.prototype._otApply = function(op, source) {
513516
if (op.op) {
514517
if (!this.type) {
515-
var err = new ShareDBError(4015, 'Cannot apply op to uncreated document. ' + this.collection + '.' + this.id);
516-
return this.emit('error', err);
518+
// Throw here, because all usage of _otApply should be wrapped with a try/catch
519+
throw new ShareDBError(4015, 'Cannot apply op to uncreated document. ' + this.collection + '.' + this.id);
517520
}
518521

519522
// Iteratively apply multi-component remote operations and rollback ops
@@ -655,8 +658,12 @@ Doc.prototype._submit = function(op, source, callback) {
655658
if (this.type.normalize) op.op = this.type.normalize(op.op);
656659
}
657660

658-
this._pushOp(op, callback);
659-
this._otApply(op, source);
661+
try {
662+
this._pushOp(op, callback);
663+
this._otApply(op, source);
664+
} catch (error) {
665+
return this._hardRollback(error);
666+
}
660667

661668
// The call to flush is delayed so if submit() is called multiple times
662669
// synchronously, all the ops are combined before being sent to the server.
@@ -867,7 +874,11 @@ Doc.prototype._rollback = function(err) {
867874
// I'm still not 100% sure about this functionality, because its really a
868875
// local op. Basically, the problem is that if the client's op is rejected
869876
// by the server, the editor window should update to reflect the undo.
870-
this._otApply(op, false);
877+
try {
878+
this._otApply(op, false);
879+
} catch (error) {
880+
return this._hardRollback(error);
881+
}
871882

872883
this._clearInflightOp(err);
873884
return;
@@ -877,22 +888,34 @@ Doc.prototype._rollback = function(err) {
877888
};
878889

879890
Doc.prototype._hardRollback = function(err) {
891+
// Store pending ops so that we can notify their callbacks of the error.
892+
// We combine the inflight op and the pending ops, because it's possible
893+
// to hit a condition where we have no inflight op, but we do have pending
894+
// ops. This can happen when an invalid op is submitted, which causes us
895+
// to hard rollback before the pending op was flushed.
896+
var pendingOps = [];
897+
if (this.inflightOp) pendingOps.push(this.inflightOp);
898+
pendingOps = pendingOps.concat(this.pendingOps);
899+
880900
// Cancel all pending ops and reset if we can't invert
881-
var op = this.inflightOp;
882-
var pending = this.pendingOps;
883901
this._setType(null);
884902
this.version = null;
885903
this.inflightOp = null;
886904
this.pendingOps = [];
887905

888-
// Fetch the latest from the server to get us back into a working state
906+
// Fetch the latest version from the server to get us back into a working state
889907
var doc = this;
890908
this.fetch(function() {
891-
var called = op && callEach(op.callbacks, err);
892-
for (var i = 0; i < pending.length; i++) {
893-
callEach(pending[i].callbacks, err);
909+
// We want to check that no errors are swallowed, so we check that:
910+
// - there are callbacks to call, and
911+
// - that every single pending op called a callback
912+
// If there are no ops queued, or one of them didn't handle the error,
913+
// then we emit the error.
914+
var allOpsHadCallbacks = !!pendingOps.length;
915+
for (var i = 0; i < pendingOps.length; i++) {
916+
allOpsHadCallbacks = callEach(pendingOps[i].callbacks, err) && allOpsHadCallbacks;
894917
}
895-
if (err && !called) return doc.emit('error', err);
918+
if (err && !allOpsHadCallbacks) return doc.emit('error', err);
896919
});
897920
};
898921

test/client/doc.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
var Backend = require('../../lib/backend');
22
var expect = require('expect.js');
3+
var util = require('../util')
34

45
describe('client query subscribe', function() {
56

@@ -212,4 +213,133 @@ describe('client query subscribe', function() {
212213

213214
});
214215

216+
describe('submitting an invalid op', function () {
217+
var doc;
218+
var invalidOp;
219+
var validOp;
220+
221+
beforeEach(function (done) {
222+
// This op is invalid because we try to perform a list deletion
223+
// on something that isn't a list
224+
invalidOp = {p: ['name'], ld: 'Scooby'};
225+
226+
validOp = {p:['snacks'], oi: true};
227+
228+
doc = this.connection.get('dogs', 'scooby');
229+
doc.create({ name: 'Scooby' }, function (error) {
230+
if (error) return done(error);
231+
doc.whenNothingPending(done);
232+
});
233+
});
234+
235+
it('returns an error to the submitOp callback', function (done) {
236+
doc.submitOp(invalidOp, function (error) {
237+
expect(error.message).to.equal('Referenced element not a list');
238+
done();
239+
});
240+
});
241+
242+
it('rolls the doc back to a usable state', function (done) {
243+
util.callInSeries([
244+
function (next) {
245+
doc.submitOp(invalidOp, function (error) {
246+
expect(error).to.be.ok();
247+
next();
248+
});
249+
},
250+
function (next) {
251+
doc.whenNothingPending(next);
252+
},
253+
function (next) {
254+
expect(doc.data).to.eql({name: 'Scooby'});
255+
doc.submitOp(validOp, next);
256+
},
257+
function (next) {
258+
expect(doc.data).to.eql({name: 'Scooby', snacks: true});
259+
next();
260+
},
261+
done
262+
]);
263+
});
264+
265+
it('rescues an irreversible op collision', function (done) {
266+
// This test case attempts to reconstruct the following corner case, with
267+
// two independent references to the same document. We submit two simultaneous, but
268+
// incompatible operations (eg one of them changes the data structure the other op is
269+
// attempting to manipulate).
270+
//
271+
// The second document to attempt to submit should have its op rejected, and its
272+
// state successfully rolled back to a usable state.
273+
var doc1 = this.backend.connect().get('dogs', 'snoopy');
274+
var doc2 = this.backend.connect().get('dogs', 'snoopy');
275+
276+
var pauseSubmit = false;
277+
var fireSubmit;
278+
this.backend.use('submit', function (request, callback) {
279+
if (pauseSubmit) {
280+
fireSubmit = function () {
281+
pauseSubmit = false;
282+
callback();
283+
};
284+
} else {
285+
fireSubmit = null;
286+
callback();
287+
}
288+
});
289+
290+
util.callInSeries([
291+
function (next) {
292+
doc1.create({colours: ['white']}, next);
293+
},
294+
function (next) {
295+
doc1.whenNothingPending(next);
296+
},
297+
function (next) {
298+
doc2.fetch(next);
299+
},
300+
function (next) {
301+
doc2.whenNothingPending(next);
302+
},
303+
// Both documents start off at the same v1 state, with colours as a list
304+
function (next) {
305+
expect(doc1.data).to.eql({colours: ['white']});
306+
expect(doc2.data).to.eql({colours: ['white']});
307+
next();
308+
},
309+
// doc1 successfully submits an op which changes our list into a string in v2
310+
function (next) {
311+
doc1.submitOp({p: ['colours'], oi: 'white,black'}, next);
312+
},
313+
// This next step is a little fiddly. We abuse the middleware to pause the op submission and
314+
// ensure that we get this repeatable sequence of events:
315+
// 1. doc2 is still on v1, where 'colours' is a list (but it's a string in v2)
316+
// 2. doc2 submits an op that assumes 'colours' is still a list
317+
// 3. doc2 fetches v2 before the op submission completes - 'colours' is no longer a list locally
318+
// 4. doc2's op is rejected by the server, because 'colours' is not a list on the server
319+
// 5. doc2 attempts to roll back the inflight op by turning a list insertion into a list deletion
320+
// 6. doc2 applies this list deletion to a field that is no longer a list
321+
// 7. type.apply throws, because this is an invalid op
322+
function (next) {
323+
pauseSubmit = true;
324+
doc2.submitOp({p: ['colours', '0'], li: 'black'}, function (error) {
325+
expect(error.message).to.equal('Referenced element not a list');
326+
next();
327+
});
328+
329+
doc2.fetch(function (error) {
330+
if (error) return next(error);
331+
fireSubmit();
332+
});
333+
},
334+
// Validate that - despite the error in doc2.submitOp - doc2 has been returned to a
335+
// workable state in v2
336+
function (next) {
337+
expect(doc1.data).to.eql({colours: 'white,black'});
338+
expect(doc2.data).to.eql(doc1.data);
339+
doc2.submitOp({p: ['colours'], oi: 'white,black,red'}, next);
340+
},
341+
done
342+
]);
343+
});
344+
});
215345
});

test/client/submit.js

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -629,16 +629,11 @@ describe('client submit', function() {
629629
doc2.del(function(err) {
630630
if (err) return done(err);
631631
doc.pause();
632-
var calledBack = false;
633-
doc.on('error', function() {
634-
expect(calledBack).equal(true);
635-
done();
636-
});
637632
doc.submitOp({p: ['age'], na: 1}, function(err) {
638-
expect(err).ok();
633+
expect(err.code).to.equal(4017);
639634
expect(doc.version).equal(2);
640635
expect(doc.data).eql(undefined);
641-
calledBack = true;
636+
done();
642637
});
643638
doc.fetch();
644639
});
@@ -658,16 +653,11 @@ describe('client submit', function() {
658653
doc2.create({age: 5}, function(err) {
659654
if (err) return done(err);
660655
doc.pause();
661-
var calledBack = false;
662-
doc.on('error', function() {
663-
expect(calledBack).equal(true);
664-
done();
665-
});
666656
doc.create({age: 9}, function(err) {
667-
expect(err).ok();
657+
expect(err.code).to.equal(4018);
668658
expect(doc.version).equal(3);
669659
expect(doc.data).eql({age: 5});
670-
calledBack = true;
660+
done();
671661
});
672662
doc.fetch();
673663
});

0 commit comments

Comments
 (0)