Skip to content

Commit 592c392

Browse files
author
Alec Gibson
committed
Add Milestone Snapshots
This non-breaking change introduces the concept of "Milestone Snapshots". A milestone snapshot is a snapshot of a document for a given version, which is persisted in the database. The purpose of this is to speed up the `Backend.fetchSnapshot` method, which currently has to fetch all the ops required to build a snapshot from v0. Instead, `fetchSnapshot` can now fetch the most recent, relevant milestone snapshot and build on top of that with fewer ops. In order to do this, the database adapter API has been updated to include two new methods: - `saveMilestoneSnapshot(collection, snapshot, callback): void;` stores the provided snapshot against the collection - `getMilestoneSnapshot(collection, id, version, callback): void` fetches the most recent snapshot whose version is equal to or less than the provided `version` (or the most recent version if version is `null`, in keeping with the `to` argument in `getOps`). The adapter also has the responsibility of saving the appropriate milestone snapshots when a new op is committed.
1 parent be70a31 commit 592c392

File tree

8 files changed

+495
-28
lines changed

8 files changed

+495
-28
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -420,3 +420,5 @@ The `41xx` and `51xx` codes are reserved for use by ShareDB DB adapters, and the
420420
* 5016 - _unsubscribe PubSub method unimplemented
421421
* 5017 - _publish PubSub method unimplemented
422422
* 5018 - Required QueryEmitter listener not assigned
423+
* 5019 - saveMilestoneSnapshot DB method unimplemented
424+
* 5020 - Milestone snapshots are disabled

lib/backend.js

+38-25
Original file line numberDiff line numberDiff line change
@@ -609,40 +609,53 @@ Backend.prototype.fetchSnapshot = function(agent, index, id, version, callback)
609609
};
610610

611611
Backend.prototype._fetchSnapshot = function (collection, id, version, callback) {
612-
// Bypass backend.getOps so that we don't call _sanitizeOps. We want to avoid this, because:
613-
// - we want to avoid the 'op' middleware, because we later use the 'readSnapshots' middleware in _sanitizeSnapshots
614-
// - we handle the projection in _sanitizeSnapshots
615-
this.db.getOps(collection, id, 0, version, null, function (error, ops) {
612+
var db = this.db;
613+
db.getMilestoneSnapshot(collection, id, version, function (error, milestoneSnapshot) {
616614
if (error) return callback(error);
617615

618-
var type = null;
619-
var data;
620-
var fetchedVersion = 0;
616+
// Bypass backend.getOps so that we don't call _sanitizeOps. We want to avoid this, because:
617+
// - we want to avoid the 'op' middleware, because we later use the 'readSnapshots' middleware in _sanitizeSnapshots
618+
// - we handle the projection in _sanitizeSnapshots
619+
var from = milestoneSnapshot ? milestoneSnapshot.v : 0;
620+
db.getOps(collection, id, from, version, null, function (error, ops) {
621+
if (error) return callback(error);
621622

622-
for (var index = 0; index < ops.length; index++) {
623-
var op = ops[index];
624-
fetchedVersion = op.v + 1;
623+
var type = null;
624+
var data;
625+
var fetchedVersion = 0;
625626

626-
if (op.create) {
627-
type = types.map[op.create.type];
627+
if (milestoneSnapshot) {
628+
type = types.map[milestoneSnapshot.type];
628629
if (!type) return callback({ code: 4008, message: 'Unknown type' });
629-
data = type.create(op.create.data);
630-
} else if (op.del) {
631-
data = undefined;
632-
type = null;
633-
} else {
634-
data = type.apply(data, op.op);
630+
data = milestoneSnapshot.data;
631+
fetchedVersion = milestoneSnapshot.v;
635632
}
636-
}
637633

638-
type = type ? type.uri : null;
634+
for (var index = 0; index < ops.length; index++) {
635+
var op = ops[index];
636+
fetchedVersion = op.v + 1;
637+
638+
if (op.create) {
639+
type = types.map[op.create.type];
640+
if (!type) return callback({ code: 4008, message: 'Unknown type' });
641+
data = type.create(op.create.data);
642+
} else if (op.del) {
643+
data = undefined;
644+
type = null;
645+
} else {
646+
data = type.apply(data, op.op);
647+
}
648+
}
639649

640-
if (version > fetchedVersion) {
641-
return callback({ code: 4024, message: 'Requested version exceeds latest snapshot version' });
642-
}
650+
type = type ? type.uri : null;
643651

644-
var snapshot = new Snapshot(id, fetchedVersion, type, data, null);
645-
callback(null, snapshot);
652+
if (version > fetchedVersion) {
653+
return callback({ code: 4024, message: 'Requested version exceeds latest snapshot version' });
654+
}
655+
656+
var snapshot = new Snapshot(id, fetchedVersion, type, data, null);
657+
callback(null, snapshot);
658+
});
646659
});
647660
};
648661

lib/db/index.js

+16
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ var ShareDBError = require('../error');
44
function DB(options) {
55
// pollDebounce is the minimum time in ms between query polls
66
this.pollDebounce = options && options.pollDebounce;
7+
8+
this.milestoneSnapshots = (options && options.milestoneSnapshots) || {};
9+
// Whether we should store/fetch milestone snapshots
10+
this.milestoneSnapshots.enabled = typeof this.milestoneSnapshots.enabled === 'boolean'
11+
? this.milestoneSnapshots.enabled
12+
: false;
13+
// The number of versions to skip before storing the next milestone snapshot
14+
this.milestoneSnapshots.interval = this.milestoneSnapshots.interval || 1000;
715
}
816
module.exports = DB;
917

@@ -103,3 +111,11 @@ DB.prototype.canPollDoc = function() {
103111
DB.prototype.skipPoll = function() {
104112
return false;
105113
};
114+
115+
DB.prototype.getMilestoneSnapshot = function(collection, id, version, callback) {
116+
callback(null, undefined);
117+
};
118+
119+
DB.prototype.saveMilestoneSnapshot = function(collection, snapshot, callback) {
120+
callback(new ShareDBError(5019, 'saveMilestoneSnapshot DB method unimplemented'));
121+
};

lib/db/memory.js

+61
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
var DB = require('./index');
22
var Snapshot = require('../snapshot');
3+
var ShareDBError = require('../error');
34

45
// In-memory ShareDB database
56
//
@@ -22,6 +23,9 @@ function MemoryDB(options) {
2223
// the list.
2324
this.ops = {};
2425

26+
// Map form collection name -> doc id -> array of milestone snapshots
27+
this._milestoneSnapshots = {};
28+
2529
this.closed = false;
2630
};
2731
module.exports = MemoryDB;
@@ -48,7 +52,16 @@ MemoryDB.prototype.commit = function(collection, id, op, snapshot, options, call
4852
if (err) return callback(err);
4953
err = db._writeSnapshotSync(collection, id, snapshot);
5054
if (err) return callback(err);
55+
5156
var succeeded = true;
57+
58+
if (db._shouldSaveMilestoneSnapshot(snapshot)) {
59+
return db.saveMilestoneSnapshot(collection, snapshot, function (error) {
60+
if (error) return callback(error);
61+
callback(null, succeeded);
62+
});
63+
}
64+
5265
callback(null, succeeded);
5366
});
5467
};
@@ -171,6 +184,54 @@ MemoryDB.prototype._getVersionSync = function(collection, id) {
171184
return (collectionOps && collectionOps[id] && collectionOps[id].length) || 0;
172185
};
173186

187+
MemoryDB.prototype.getMilestoneSnapshot = function (collection, id, version, callback) {
188+
if (!this.milestoneSnapshots.enabled) {
189+
return callback(null, undefined);
190+
}
191+
192+
var milestoneSnapshots = this._getMilestoneSnapshotsSync(collection, id);
193+
194+
let milestoneSnapshot;
195+
for (var i = 0; i < milestoneSnapshots.length; i++) {
196+
var nextMilestoneSnapshot = milestoneSnapshots[i];
197+
if (nextMilestoneSnapshot.v <= version || version === null) {
198+
milestoneSnapshot = nextMilestoneSnapshot;
199+
} else {
200+
break;
201+
}
202+
}
203+
204+
callback(null, milestoneSnapshot);
205+
};
206+
207+
MemoryDB.prototype.saveMilestoneSnapshot = function (collection, snapshot, callback) {
208+
if (!this.milestoneSnapshots.enabled) {
209+
return callback(new ShareDBError(5020, 'Milestone snapshots are disabled'));
210+
}
211+
212+
if (!snapshot) {
213+
return callback(null);
214+
}
215+
216+
var milestoneSnapshots = this._getMilestoneSnapshotsSync(collection, snapshot.id);
217+
milestoneSnapshots.push(snapshot);
218+
milestoneSnapshots.sort(function (a, b) {
219+
return a.v - b.v;
220+
});
221+
222+
callback(null);
223+
};
224+
225+
MemoryDB.prototype._getMilestoneSnapshotsSync = function (collection, id) {
226+
var collectionSnapshots = this._milestoneSnapshots[collection] || (this._milestoneSnapshots[collection] = {});
227+
return collectionSnapshots[id] || (collectionSnapshots[id] = []);
228+
};
229+
230+
MemoryDB.prototype._shouldSaveMilestoneSnapshot = function (snapshot) {
231+
return this.milestoneSnapshots.enabled
232+
&& snapshot.v % this.milestoneSnapshots.interval === 0;
233+
}
234+
174235
function clone(obj) {
175236
return (obj === undefined) ? undefined : JSON.parse(JSON.stringify(obj));
176237
}

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"expect.js": "^0.3.1",
1717
"istanbul": "^0.4.2",
1818
"jshint": "^2.9.2",
19-
"mocha": "^5.2.0"
19+
"mocha": "^5.2.0",
20+
"sinon": "^6.1.5"
2021
},
2122
"scripts": {
2223
"test": "./node_modules/.bin/mocha && npm run jshint",

test/client/snapshot-request.js

+51
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
var Backend = require('../../lib/backend');
22
var expect = require('expect.js');
3+
var MemoryDB = require('../../lib/db/memory');
4+
var sinon = require('sinon');
35

46
describe('SnapshotRequest', function () {
57
var backend;
@@ -353,4 +355,53 @@ describe('SnapshotRequest', function () {
353355
});
354356
});
355357
});
358+
359+
describe('milestone snapshots enabled for every other version', function () {
360+
var db;
361+
362+
beforeEach(function (done) {
363+
var options = {
364+
milestoneSnapshots: {
365+
enabled: true,
366+
interval: 2
367+
}
368+
};
369+
db = new MemoryDB(options);
370+
backend = new Backend({ db: db });
371+
372+
var tests = this;
373+
db.saveMilestoneSnapshot('test-implementation', undefined, function (error) {
374+
// Only run this test block if milestone snapshots are implemented on the driver
375+
if (error) {
376+
if (error.code === 5019) return tests.skip();
377+
if (error.code !== 5020) return done(error);
378+
}
379+
done();
380+
});
381+
});
382+
383+
it('fetches a snapshot using the milestone', function (done) {
384+
var doc = backend.connect().get('books', 'mocking-bird');
385+
doc.create({ title: 'To Kill a Mocking Bird' }, function (error) {
386+
if (error) return done(error);
387+
doc.submitOp({ p: ['author'], oi: 'Harper Lea' }, function (error) {
388+
if (error) return done(error);
389+
doc.submitOp({ p: ['author'], od: 'Harper Lea', oi: 'Harper Lee' }, function (error) {
390+
if (error) return done(error);
391+
sinon.spy(db, 'getMilestoneSnapshot');
392+
sinon.spy(db, 'getOps');
393+
backend.connect().fetchSnapshot('books', 'mocking-bird', 3, function (error, snapshot) {
394+
if (error) return done(error);
395+
expect(db.getMilestoneSnapshot.calledOnce).to.be(true);
396+
expect(db.getOps.calledWith('books', 'mocking-bird', 2, 3)).to.be(true);
397+
398+
expect(snapshot.v).to.be(3);
399+
expect(snapshot.data).to.eql({ title: 'To Kill a Mocking Bird', author: 'Harper Lee' });
400+
done();
401+
});
402+
});
403+
});
404+
});
405+
});
406+
});
356407
});

test/db-memory.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,12 @@ function snapshotComparator(sortProperties) {
119119

120120
// Run all the DB-based tests against the BasicQueryableMemoryDB.
121121
require('./db')({
122-
create: function(callback) {
123-
var db = new BasicQueryableMemoryDB();
122+
create: function(options, callback) {
123+
if (typeof options === 'function') {
124+
callback = options;
125+
options = null;
126+
}
127+
var db = new BasicQueryableMemoryDB(options);
124128
callback(null, db);
125129
},
126130
getQuery: function(options) {

0 commit comments

Comments
 (0)