Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/presence.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Optional

### unsubscribe()

Unsubscribe from presence updates from remote clients.
Unsubscribe from presence updates from remote clients. If the channel was subscribed, or a subscription was pending, any presence this client has submitted on it is broadcast to remote subscribers as `null`.

```javascript
presence.unsubscribe([callback])
Expand Down
4 changes: 4 additions & 0 deletions docs/middleware/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ This action has these additional `context` properties:
`presence` -- Object

> The presence object that was received. Its shape depends on its [type]({{ site.baseurl }}{% link types/index.md %})
>
> `presence.p` is `null` when the presence leaves the channel: when it is destroyed, or when its client unsubscribes or disconnects. When its client unsubscribes or disconnects, the leave reuses the client's latest `presence` object for that ID, which passes through this middleware again with `p` set to `null` and any changes from its first pass kept, even if it is still being processed. Middleware with side effects should expect to see the same presence object twice.
>
> A presence that passes this middleware may still not be broadcast. If a newer presence with the same ID from the same client, or its leave, replaces it before it is published, its client gets an acknowledgement but nobody else receives it.

## `'sendPresence'`

Expand Down
168 changes: 123 additions & 45 deletions lib/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,26 @@ function Agent(backend, stream) {
this.presenceSubscriptionSeq = Object.create(null);
// Keep track of the last request that has been sent by each local presence
// belonging to this agent. This is used to generate a new disconnection
// request if the client disconnects ungracefully. This is a
// map of channel -> id -> request
// request if the client disconnects ungracefully, and a presence that's
// been replaced here by the time it would be published isn't sent. This
// is a map of channel -> id -> request
this.presenceRequests = Object.create(null);
// Keep track of the latest known Doc version, so that we can avoid fetching
// ops to transform presence if not needed
this.latestDocVersionStreams = Object.create(null);
this.latestDocVersions = Object.create(null);

// Every doc, doc version and presence stream this agent holds, so that
// _cleanup() destroys them even if they've gone missing from their own map.
// Map of key -> stream
this._streams = Object.create(null);
this._nextStreamKey = 1;
Comment on lines +60 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 Readability · nit] _streams and _nextStreamKey are the only new fields with no comment. _streams is also easy to confuse with this.stream (the client connection) and with the per-path maps above it, and a reader can't tell that it leaves out query emitters.

Suggestion:

// Every pub/sub stream this agent holds (doc, doc version and presence
// subscriptions). Streams remove themselves on 'close', and _cleanup()
// destroys the rest, even ones no longer in their own map
this._streams = Object.create(null);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Every other field in that constructor documents its map, so these do too.

// Presence broadcasts still in their doc version lookup or receivePresence
// middleware. _retractPresences() flags them to go out as a leave, and a
// leave from the client that gets through middleware flags its own older
// ones to be dropped
this._pendingPresenceBroadcasts = [];

// We need to track this manually to make sure we don't reply to messages
// after the stream was closed.
this.closed = false;
Expand Down Expand Up @@ -93,33 +105,33 @@ Agent.prototype._cleanup = function() {
this.backend.agentsCount--;
if (!this.stream.isServer) this.backend.remoteAgentsCount--;

// Clean up doc subscription streams
for (var collection in this.subscribedDocs) {
var docs = this.subscribedDocs[collection];
for (var id in docs) {
var stream = docs[id];
stream.destroy();
}
for (var channel in this.presenceRequests) {
this._retractPresences(channel);
}
this.subscribedDocs = Object.create(null);

for (var channel in this.subscribedPresences) {
this.subscribedPresences[channel].destroy();
for (var key in this._streams) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 Compatibility · nit] _cleanup() no longer resets subscribedDocs, subscribedPresences and latestDocVersionStreams synchronously. They empty out only when each stream's 'end' handler runs, a tick later.

On Node 24, reading straight after agent._cleanup() gave HEAD subscribedDocs ['c'], subscribedPresences ['c.d'] and base [] []. Nothing in lib reads them after close, but plugins listening to the client stream's 'close' would now see destroyed streams.

Suggestion: keep the old resets after the _streams loop. They cost nothing and keep the agent state the same as 6.0.5.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored. Test: "empties its stream maps as soon as the connection closes".

this._streams[key].destroy();
}
Comment on lines +108 to 114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 Readability · nit] The PR removed the // Clean up doc subscription streams comment, and the two new loops have none, while the query loop below keeps its comment. So _cleanup is now half documented, and a reader comparing it with base will wonder whether the map resets were dropped on purpose.

Suggestion:

// Tell remote subscribers this client's presence has left
for (var channel in this.presenceRequests) { … }

// Destroy doc, doc version and presence streams. Their maps empty
// out through each stream's 'end' handler
for (var key in this._streams) { … }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather leave these out. They'd restate _retractPresences() and destroy(), and the resets are back, so there's nothing left for a reader comparing it with base to wonder about.

this.subscribedDocs = Object.create(null);
this.subscribedPresences = Object.create(null);
this.latestDocVersionStreams = Object.create(null);

// Clean up query subscription streams
for (var id in this.subscribedQueries) {
var emitter = this.subscribedQueries[id];
emitter.destroy();
}
this.subscribedQueries = Object.create(null);
};

for (var collection in this.latestDocVersionStreams) {
var streams = this.latestDocVersionStreams[collection];
for (var id in streams) streams[id].destroy();
}
this.latestDocVersionStreams = Object.create(null);
Agent.prototype._trackStream = function(stream) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 Performance + Readability · praise] _trackStream follows the same counter-key plus once('close') self-removal pattern as PubSub#_createStream, so it reads as familiar. It also holds up under scrutiny:

  • keys can't collide;
  • destroying twice does nothing, because of OpStream's open guard;
  • deleting inside _cleanup's for-in loop is defined behaviour;
  • streams that arrive after close are destroyed before they are tracked.

The cost is about 150-200 B per stream, which is roughly 2 MB for a client holding 10k doc subscriptions.

if (stream.open === false) return;
var streams = this._streams;
var key = this._nextStreamKey++;
streams[key] = stream;
stream.once('close', function() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 Correctness · nit] once('close') is registered on a stream that may already be closed. OpStream#destroy emits 'close' synchronously and only once, so such a stream stays in _streams until the agent closes.

This can only happen if pubsub.close() runs between pubsub.subscribe and backend.fetch completing in backend.subscribe. agent.closed is still false at that point, because backend.close() doesn't close agents. It's shutdown-only and bounded.

Suggestion: at the top of _trackStream, add if (!stream.open) return;, with a comment that the stream has already closed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, as if (stream.open === false) return;, so a stream from an adapter without the flag is still tracked. Test: "does not track a stream that closed before reaching the agent".

delete streams[key];
});
};
Comment on lines +127 to 135

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 Tests & conventions · should] No test checks that _trackStream drops a stream from _streams when the stream closes. A long-lived connection could build up a map of dead streams and CI would stay green.

Mutation test: deleting the stream.once('close', … delete streams[key]) listener still gives 902 passing, 0 failing. The new cleanup tests assert backend.pubsub.streamsCount, which drops on destroy whether or not the agent's map is pruned. No test reads agent._streams. A script that subscribes and unsubscribes shows it does drain today; the tests just don't pin that.

Suggestion: in the cleanup describe block, subscribe and unsubscribe a doc and a presence channel, and submit and then destroy a doc presence (doc-version stream). Then assert expect(connection.agent._streams).to.be.empty before closing. Existing tests already read agent internals such as presenceRequests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added "stops tracking streams once they close". It covers doc subscribe/unsubscribe, presence subscribe/unsubscribe, and a doc presence submit then destroy, and expects agent._streams to be empty. Deleting the 'close' listener now fails it.


/**
Expand All @@ -142,6 +154,7 @@ Agent.prototype._subscribeToStream = function(collection, id, stream) {

Agent.prototype._subscribeMapToStream = function(map, collection, id, stream, dataHandler) {
if (this.closed) return stream.destroy();
this._trackStream(stream);

var streams = map[collection] || (map[collection] = Object.create(null));

Expand All @@ -163,8 +176,13 @@ Agent.prototype._subscribeMapToStream = function(map, collection, id, stream, da

Agent.prototype._subscribeToPresenceStream = function(channel, stream) {
if (this.closed) return stream.destroy();
this._trackStream(stream);
var agent = this;

var previous = this.subscribedPresences[channel];
if (previous) previous.destroy();
this.subscribedPresences[channel] = stream;

stream.on('data', function(data) {
if (data.error) {
logger.error('Presence subscription stream error', channel, data.error);
Expand All @@ -173,19 +191,34 @@ Agent.prototype._subscribeToPresenceStream = function(channel, stream) {
});

stream.on('end', function() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 Compatibility + Operability · question] Now that this 'end' handler only deletes the map entry, a presence stream that ends for any reason other than unsubscribe or agent cleanup no longer sends a leave.

The concrete case is backend.close(), which calls pubsub.close() while clients are still connected. On base, the 'end' handler sent best-effort leaves. Now no leave is sent, because agents aren't closed, so _cleanup and _retractPresences never run. With an in-memory pubsub those leaves already reached nobody. With redis pubsub across processes, they could reach other nodes, so after a graceful shutdown users connected through other servers may now keep ghosts.

Was the base behaviour on backend.close() intended? If so, backend.close() could close its agents first. Otherwise, say in the migration note that leaves now depend on the client connection closing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not intended, as far as I can tell. On base those leaves were a side effect of the presence stream ending: best-effort, and racing the same pubsub/db shutdown that triggered them (doc presence leaves need getOps from the db that's closing). Leaves now follow the client connection closing, which is what happens once a server shuts down anyway. I've said so in the migration note rather than making backend.close() close its agents, which it has no list of.

var requests = agent.presenceRequests[channel] || {};
for (var id in requests) {
var request = agent.presenceRequests[channel][id];
request.seq++;
request.p = null;
agent._broadcastPresence(request, function(error) {
if (error) logger.error('Error broadcasting disconnect presence', channel, error);
});
}
if (agent.subscribedPresences[channel] === stream) {
delete agent.subscribedPresences[channel];
}
delete agent.presenceRequests[channel];
});
};

Agent.prototype._retractPresences = function(channel) {
var pendingBroadcasts = this._pendingPresenceBroadcasts;
for (var i = 0; i < pendingBroadcasts.length; i++) {
if (pendingBroadcasts[i].channel === channel) pendingBroadcasts[i].retracted = true;
}

var requests = this.presenceRequests[channel];
for (var id in requests) {
var request = requests[id];
var alreadyLeft = request.p === null;
if (alreadyLeft) this._unsubscribeDocVersion(request.c, request.d, function() {});
else this._broadcastLeave(request);
}
delete this.presenceRequests[channel];
};

Agent.prototype._broadcastLeave = function(presence, callback) {
var agent = this;
presence.p = null;
this._broadcastPresence(presence, [], function(error, result) {
if (error) logger.error('Error broadcasting disconnect presence', presence.ch, presence.id, agent._src(), error);
if (callback) callback(error, result);
});
};

Expand Down Expand Up @@ -522,7 +555,8 @@ Agent.prototype._handleMessage = function(request, callback) {
message: 'Type does not support presence: ' + presence.t
});
}
return this._broadcastPresence(presence, callback);
var olderBroadcasts = presence.p === null ? this._olderPendingPresenceBroadcasts(presence) : [];
return this._broadcastPresence(presence, olderBroadcasts, callback);
case ACTIONS.presenceSubscribe:
if (!this.backend.presenceEnabled) return;
return this._subscribePresence(request.ch, request.seq, callback);
Expand Down Expand Up @@ -843,28 +877,43 @@ Agent.prototype._src = function() {
return this.src || this.clientId;
};

Agent.prototype._broadcastPresence = function(presence, callback) {
Agent.prototype._broadcastPresence = function(presence, supersededBroadcasts, callback) {
var agent = this;
var backend = this.backend;
var presenceRequests = this.presenceRequests;
var leaving = presence.p === null;
var pendingBroadcast = this._addPendingPresenceBroadcast(presence);
var context = {
presence: presence,
collection: presence.c
};
var start = Date.now();

var subscriptionUpdater = presence.p === null ?
var subscriptionUpdater = leaving ?
this._unsubscribeDocVersion.bind(this) :
this._subscribeDocVersion.bind(this);

subscriptionUpdater(presence.c, presence.d, function(error) {
if (error) return callback(error);
if (error) {
agent._removePendingPresenceBroadcast(pendingBroadcast);
return callback(error);
}
backend.trigger(backend.MIDDLEWARE_ACTIONS.receivePresence, agent, context, function(error) {
agent._removePendingPresenceBroadcast(pendingBroadcast);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🤖 Operability + Intent + Correctness · should] The pending-broadcast guard ends here, but the transform (getOps) and publish still run after it. A presence in that window that isn't the stored requests[id] can still publish live after the leave.

  1. The client sends pv1 and pv2 for the same id without waiting for acks (local-presence.js:30-54).
  2. pv1 finishes middleware second, through async middleware or two concurrent backend.subscribes in _subscribeDocVersion. Line 863 doesn't store it, because pv2 is newer.
  3. The client disconnects while pv1 is in transformPresenceToLatestVersion. _retractPresences nulls only the stored pv2.
  4. pv1 publishes its real p after the null. On receivers, the null already ran _removeRemotePresence (client/presence/presence.js:136-137), which dropped the pv high-water mark. pv1 lands on a fresh RemotePresence with presenceVersion 0, is accepted, and leaves a ghost.

The stored-request case is only safe because the transform shares the object and mutates it in place. #748 lists the transform as one of its windows, but none of the new tests holds a broadcast inside it.

Suggestion: keep the pending entry until just before pubsub.publish, removing it in a single exit wrapper around callback. Re-check agent.closed || pendingBroadcast.retracted at that point, then publish p: null or drop the stale one. To test it: make the presence stale (as in doc-presence.js:253), hold agent1's op middleware (fired via getOps → _sanitizeOp), unsubscribe inside it, release it, and assert the last receive is null.

Dissent (Operability): base had the same hole, and a wider one, so this narrows #748 rather than regressing it. It could be a follow-up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, but with a publish-time check rather than keeping the pending entry: once the transform finishes, a presence that's no longer the stored presenceRequests[ch][id] (because a newer one from the same client, or its leave, has replaced it) is acknowledged but not published. That needs no extra bookkeeping, and it also covers #763, so the commit now closes that too. Leaves are exempt, because _cleanup() deletes the channel's map while one may still be in its transform.

A presence re-created with the same id starts again at pv 0, so any presence now replaces a stored leave whatever its pv. Otherwise the check above would drop it. That would also let an older presence still in flight replace its own client's later leave, which happens without any middleware, because a doc presence's first submit waits on a DB subscribe and its leave doesn't. So a leave from the client now flags its older pending broadcasts with the same id, and they're dropped as they leave middleware, releasing the doc version they subscribed to after the leave had already released it. _pendingPresenceBroadcasts is already in arrival order, so that needs no new bookkeeping. Async receive middleware can still reorder a presence and its own leave before either reaches this point. That's no worse than 7.0.0, and I've filed #767 for it.

Tests:

  • "does not broadcast an older presence that finishes transforming after its client unsubscribes" (holds agent1's op middleware, as you suggested)
  • "broadcasts a null presence still being transformed when the connection closes"
  • "does not broadcast presence still looking up its doc version when it is destroyed"
  • "does not broadcast presence that gets through middleware after its own leave"
  • the two "re-created with the same id" tests

if (error) return callback(error);
var requests = presenceRequests[presence.ch] || (presenceRequests[presence.ch] = Object.create(null));
for (var i = 0; i < supersededBroadcasts.length; i++) {
supersededBroadcasts[i].superseded = true;
}
if (pendingBroadcast.superseded && !leaving) {
return agent._unsubscribeDocVersion(presence.c, presence.d, function() {
callback(null, presence);
});
}
var clientHasLeft = agent.closed || pendingBroadcast.retracted;
if (clientHasLeft && !leaving) return agent._broadcastLeave(presence, callback);
var requests = agent.presenceRequests[presence.ch] || (agent.presenceRequests[presence.ch] = Object.create(null));
var previousRequest = requests[presence.id];
if (!previousRequest || previousRequest.pv < presence.pv) {
presenceRequests[presence.ch][presence.id] = presence;
if (!previousRequest || previousRequest.p === null || previousRequest.pv < presence.pv) {
requests[presence.id] = presence;
}

var transformer = function(agent, presence, callback) {
Expand All @@ -877,19 +926,52 @@ Agent.prototype._broadcastPresence = function(presence, callback) {
transformer = backend.transformPresenceToLatestVersion.bind(backend);
}

transformer(agent, presence, function(error, presence) {
if (error) return callback(error);
var channel = agent._getPresenceChannel(presence.ch);
agent.backend.pubsub.publish([channel], presence, function(error) {
transformer(agent, presence, function(error, transformedPresence) {
if (error) {
agent._withdrawPresenceRequest(requests, presence, previousRequest);
return callback(error);
}
var replaced = util.dig(agent.presenceRequests, presence.ch, presence.id) !== presence;
if (replaced && !leaving) return callback(null, transformedPresence);
Comment thread
alecgibson marked this conversation as resolved.
var channel = agent._getPresenceChannel(transformedPresence.ch);
agent.backend.pubsub.publish([channel], transformedPresence, function(error) {
if (error) return callback(error);
backend.emit('timing', 'presence.broadcast', Date.now() - start, context);
callback(null, presence);
callback(null, transformedPresence);
});
});
});
});
};

Agent.prototype._withdrawPresenceRequest = function(requests, presence, previousRequest) {
if (requests[presence.id] !== presence) return;
if (previousRequest) requests[presence.id] = previousRequest;
else presence.p = null;
};

Agent.prototype._addPendingPresenceBroadcast = function(presence) {
var broadcast = {channel: presence.ch, id: presence.id, pv: presence.pv, retracted: false, superseded: false};
this._pendingPresenceBroadcasts.push(broadcast);
return broadcast;
};

Agent.prototype._removePendingPresenceBroadcast = function(broadcast) {
var index = this._pendingPresenceBroadcasts.indexOf(broadcast);
if (index !== -1) this._pendingPresenceBroadcasts.splice(index, 1);
};

Agent.prototype._olderPendingPresenceBroadcasts = function(presence) {
var olderBroadcasts = [];
var pendingBroadcasts = this._pendingPresenceBroadcasts;
for (var i = 0; i < pendingBroadcasts.length; i++) {
var broadcast = pendingBroadcasts[i];
var isOlder = broadcast.channel === presence.ch && broadcast.id === presence.id && broadcast.pv < presence.pv;
if (isOlder) olderBroadcasts.push(broadcast);
}
return olderBroadcasts;
};

Agent.prototype._subscribeDocVersion = function(collection, id, callback) {
if (!collection || !id) return callback();

Expand Down Expand Up @@ -957,11 +1039,6 @@ Agent.prototype._subscribePresence = function(channel, seq, cb) {
return callback();
}
agent.presenceSubscriptionSeq[channel] = seq;
if (agent.subscribedPresences[channel]) {
stream.destroy();
return callback();
}
agent.subscribedPresences[channel] = stream;
agent._subscribeToPresenceStream(channel, stream);
agent._requestPresence(channel, function(error) {
callback(error);
Expand All @@ -974,6 +1051,7 @@ Agent.prototype._unsubscribePresence = function(channel, seq, callback) {
var stream = this.subscribedPresences[channel];
if (stream) stream.destroy();
delete this.subscribedPresences[channel];
this._retractPresences(channel);
callback(null, {ch: channel, seq: seq});
};

Expand Down
Loading
Loading