Skip to content

Commit 87ef11d

Browse files
committed
⚡️ Cache latest op version when broadcasting presence
At the moment, when sending a presence update to other subscribers, we [call `transformPresenceToLatestVersion()`][1] for every presence update which internally [calls `getOps()`][2] for every presence update. Calls to `getOps()` can be expensive, and rapid presence updates may cause undue load on the server, even when the `Doc` has not been updated. This change tries to mitigate this by subscribing to a pubsub stream for any `Doc`s that an `Agent` tries to broadcast presence on. We keep an in-memory cache of the latest snapshot version sent over this stream, which lets us quickly check if a presence broadcast is already current without needing to query the database at all. To avoid leaking streams, the `Agent` will internally handle its stream subscription state by: - subscribing whenever a non-`null` presence update is broadcast - unsubscribing whenever a `null` presence update is broadcast This means that rapid changes in presence being `null` or not can still result in database calls, but even in this case they should be less bad than before, because we only perform a snapshot fetch instead of ops. [1]: https://github.com/share/sharedb/blob/297ce5dc66563a5955311793a475768d73ac8b87/lib/agent.js#L804 [2]: https://github.com/share/sharedb/blob/297ce5dc66563a5955311793a475768d73ac8b87/lib/backend.js#L919
1 parent 297ce5d commit 87ef11d

File tree

2 files changed

+133
-23
lines changed

2 files changed

+133
-23
lines changed

lib/agent.js

+95-23
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ function Agent(backend, stream) {
4848
// request if the client disconnects ungracefully. This is a
4949
// map of channel -> id -> request
5050
this.presenceRequests = Object.create(null);
51+
// Keep track of the latest known Doc version, so that we can avoid fetching
52+
// ops to transform presence if not needed
53+
this.latestDocVersionStreams = Object.create(null);
54+
this.latestDocVersions = Object.create(null);
5155

5256
// We need to track this manually to make sure we don't reply to messages
5357
// after the stream was closed.
@@ -108,24 +112,21 @@ Agent.prototype._cleanup = function() {
108112
emitter.destroy();
109113
}
110114
this.subscribedQueries = Object.create(null);
115+
116+
for (var collection in this.latestDocVersionStreams) {
117+
var streams = this.latestDocVersionStreams[collection];
118+
for (var id in streams) streams[id].destroy();
119+
}
120+
this.latestDocVersionStreams = Object.create(null);
111121
};
112122

113123
/**
114124
* Passes operation data received on stream to the agent stream via
115125
* _sendOp()
116126
*/
117127
Agent.prototype._subscribeToStream = function(collection, id, stream) {
118-
if (this.closed) return stream.destroy();
119-
120-
var streams = this.subscribedDocs[collection] || (this.subscribedDocs[collection] = Object.create(null));
121-
122-
// If already subscribed to this document, destroy the previously subscribed stream
123-
var previous = streams[id];
124-
if (previous) previous.destroy();
125-
streams[id] = stream;
126-
127128
var agent = this;
128-
stream.on('data', function(data) {
129+
this._subscribeMapToStream(this.subscribedDocs, collection, id, stream, function(data) {
129130
if (data.error) {
130131
// Log then silently ignore errors in a subscription stream, since these
131132
// may not be the client's fault, and they were not the result of a
@@ -135,13 +136,26 @@ Agent.prototype._subscribeToStream = function(collection, id, stream) {
135136
}
136137
agent._onOp(collection, id, data);
137138
});
139+
};
140+
141+
Agent.prototype._subscribeMapToStream = function(map, collection, id, stream, dataHandler) {
142+
if (this.closed) return stream.destroy();
143+
144+
var streams = map[collection] || (map[collection] = Object.create(null));
145+
146+
// If already subscribed to this document, destroy the previously subscribed stream
147+
var previous = streams[id];
148+
if (previous) previous.destroy();
149+
streams[id] = stream;
150+
151+
stream.on('data', dataHandler);
138152
stream.on('end', function() {
139153
// The op stream is done sending, so release its reference
140-
var streams = agent.subscribedDocs[collection];
154+
var streams = map[collection];
141155
if (!streams || streams[id] !== stream) return;
142156
delete streams[id];
143157
if (util.hasKeys(streams)) return;
144-
delete agent.subscribedDocs[collection];
158+
delete map[collection];
145159
});
146160
};
147161

@@ -794,25 +808,83 @@ Agent.prototype._broadcastPresence = function(presence, callback) {
794808
collection: presence.c
795809
};
796810
var start = Date.now();
797-
backend.trigger(backend.MIDDLEWARE_ACTIONS.receivePresence, this, context, function(error) {
811+
812+
var subscriptionUpdater = presence.p === null ?
813+
this._unsubscribeDocVersion.bind(this) :
814+
this._subscribeDocVersion.bind(this);
815+
816+
subscriptionUpdater(presence.c, presence.d, function(error) {
798817
if (error) return callback(error);
799-
var requests = presenceRequests[presence.ch] || (presenceRequests[presence.ch] = Object.create(null));
800-
var previousRequest = requests[presence.id];
801-
if (!previousRequest || previousRequest.pv < presence.pv) {
802-
presenceRequests[presence.ch][presence.id] = presence;
803-
}
804-
backend.transformPresenceToLatestVersion(agent, presence, function(error, presence) {
818+
backend.trigger(backend.MIDDLEWARE_ACTIONS.receivePresence, agent, context, function(error) {
805819
if (error) return callback(error);
806-
var channel = agent._getPresenceChannel(presence.ch);
807-
agent.backend.pubsub.publish([channel], presence, function(error) {
808-
if (error) return callback(error);
809-
backend.emit('timing', 'presence.broadcast', Date.now() - start, context);
820+
var requests = presenceRequests[presence.ch] || (presenceRequests[presence.ch] = Object.create(null));
821+
var previousRequest = requests[presence.id];
822+
if (!previousRequest || previousRequest.pv < presence.pv) {
823+
presenceRequests[presence.ch][presence.id] = presence;
824+
}
825+
826+
var transformer = function(agent, presence, callback) {
810827
callback(null, presence);
828+
};
829+
830+
var latestDocVersion = util.dig(agent.latestDocVersions, presence.c, presence.d);
831+
var presenceIsUpToDate = presence.v === latestDocVersion;
832+
if (!presenceIsUpToDate) {
833+
// null presence can't be transformed, so skip the database call and just
834+
// set the version to the latest known Doc version
835+
if (presence.p === null) {
836+
transformer = function(agent, presence, callback) {
837+
presence.v = latestDocVersion;
838+
callback(null, presence);
839+
};
840+
} else {
841+
transformer = backend.transformPresenceToLatestVersion.bind(backend);
842+
}
843+
}
844+
845+
transformer(agent, presence, function(error, presence) {
846+
if (error) return callback(error);
847+
var channel = agent._getPresenceChannel(presence.ch);
848+
agent.backend.pubsub.publish([channel], presence, function(error) {
849+
if (error) return callback(error);
850+
backend.emit('timing', 'presence.broadcast', Date.now() - start, context);
851+
callback(null, presence);
852+
});
811853
});
812854
});
813855
});
814856
};
815857

858+
Agent.prototype._subscribeDocVersion = function(collection, id, callback) {
859+
if (!collection || !id) return callback();
860+
861+
var latestDocVersions = this.latestDocVersions;
862+
var isSubscribed = util.dig(latestDocVersions, collection, id) !== undefined;
863+
if (isSubscribed) return callback();
864+
865+
var agent = this;
866+
this.backend.subscribe(this, collection, id, null, function(error, stream, snapshot) {
867+
if (error) return callback(error);
868+
869+
util.digOrCreate(latestDocVersions, collection, id, function() {
870+
return snapshot.v;
871+
});
872+
873+
agent._subscribeMapToStream(agent.latestDocVersionStreams, collection, id, stream, function(op) {
874+
// op.v behind snapshot.v by 1
875+
latestDocVersions[collection][id] = op.v + 1;
876+
});
877+
878+
callback();
879+
});
880+
};
881+
882+
Agent.prototype._unsubscribeDocVersion = function(collection, id, callback) {
883+
var stream = util.dig(this.latestDocVersionStreams, collection, id);
884+
if (stream) stream.destroy();
885+
util.nextTick(callback);
886+
};
887+
816888
Agent.prototype._createPresence = function(request) {
817889
return {
818890
a: ACTIONS.presence,

test/client/presence/doc-presence.js

+38
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ var types = require('../../../lib/types');
55
var presenceTestType = require('./presence-test-type');
66
var errorHandler = require('../../util').errorHandler;
77
var PresencePauser = require('./presence-pauser');
8+
var sinon = require('sinon');
89
types.register(presenceTestType.type);
910

1011
describe('DocPresence', function() {
@@ -297,6 +298,43 @@ describe('DocPresence', function() {
297298
], done);
298299
});
299300

301+
it('does not call getOps() when presence is already up-to-date', function(done) {
302+
var localPresence1 = presence1.create('presence-1');
303+
304+
async.series([
305+
doc1.fetch.bind(doc1), // Ensure up-to-date
306+
function(next) {
307+
sinon.spy(Backend.prototype, 'getOps');
308+
next();
309+
},
310+
localPresence1.submit.bind(localPresence1, {index: 1}),
311+
function(next) {
312+
expect(Backend.prototype.getOps).not.to.have.been.called;
313+
next();
314+
}
315+
], done);
316+
});
317+
318+
it('does not call getOps() for old presence when it is null', function(done) {
319+
var localPresence1 = presence1.create('presence-1');
320+
321+
async.series([
322+
doc1.unsubscribe.bind(doc1),
323+
doc2.submitOp.bind(doc2, {index: 5, value: 'ern'}),
324+
function(next) {
325+
expect(doc1.version).to.eql(1);
326+
expect(doc2.version).to.eql(2);
327+
328+
sinon.spy(Backend.prototype, 'getOps');
329+
localPresence1.submit(null, function(error) {
330+
if (error) return next(error);
331+
expect(Backend.prototype.getOps).not.to.have.been.called;
332+
next();
333+
});
334+
}
335+
], done);
336+
});
337+
300338
// This test case attempts to force us into a tight race condition corner case:
301339
// 1. doc1 sends presence, as well as submits an op
302340
// 2. doc2 receives the op first, followed by the presence, which is now out-of-date

0 commit comments

Comments
 (0)