@fluidframework/presence 2.70.0-361788 → 2.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,6 +53,27 @@ function mergeGeneralDatastoreMessageContent(base, newData) {
53
53
  }
54
54
  return queueDatastore;
55
55
  }
56
+ /**
57
+ * Delays used for broadcasting join responses to clients.
58
+ *
59
+ * @remarks
60
+ * Exported for test coordination.
61
+ * These could be made customizable in the future to accommodate different
62
+ * session configurations.
63
+ */
64
+ export const broadcastJoinResponseDelaysMs = {
65
+ /**
66
+ * The delay in milliseconds before a join response is sent to any client.
67
+ * This is used to accumulate other join response requests and reduce
68
+ * network traffic.
69
+ */
70
+ namedResponder: 200,
71
+ /**
72
+ * The additional delay in milliseconds a backup responder waits before sending
73
+ * a join response to allow others to respond first.
74
+ */
75
+ backupResponderIncrement: 40,
76
+ };
56
77
  /**
57
78
  * Manages singleton datastore for all Presence.
58
79
  */
@@ -65,17 +86,92 @@ export class PresenceDatastoreManagerImpl {
65
86
  this.presence = presence;
66
87
  this.averageLatency = 0;
67
88
  this.returnedMessages = 0;
68
- this.refreshBroadcastRequested = false;
69
- this.timer = new TimerManager();
89
+ this.sendMessageTimer = new TimerManager();
70
90
  this.workspaces = new Map();
91
+ /**
92
+ * Map of outstanding broadcast (join response) requests.
93
+ */
94
+ this.broadcastRequests = new Map();
95
+ /**
96
+ * Timer for managing broadcast (join response) request timing.
97
+ */
98
+ this.broadcastRequestsTimer = new TimerManager();
99
+ /**
100
+ * Broadcasts a join response (complete datastore update message)
101
+ * if there is an outstanding join response request.
102
+ */
103
+ this.sendJoinResponseIfStillNeeded = () => {
104
+ // Make sure we are currently connected and a broadcast is still needed.
105
+ // If not connected, nothing we can do.
106
+ if (this.runtime.getJoinedStatus() !== "disconnected" && this.broadcastRequests.size > 0) {
107
+ // Confirm that of remaining requests, now is the time to respond.
108
+ const now = Date.now();
109
+ let minResponseTime = Number.POSITIVE_INFINITY;
110
+ for (const { deadlineTime } of this.broadcastRequests.values()) {
111
+ minResponseTime = Math.min(minResponseTime, deadlineTime);
112
+ }
113
+ if (minResponseTime <= now) {
114
+ if (this.reasonForCompleteSnapshot) {
115
+ this.broadcastAllKnownState();
116
+ }
117
+ }
118
+ else {
119
+ // No response needed yet - schedule a later attempt
120
+ this.broadcastRequestsTimer.setTimeout(this.sendJoinResponseIfStillNeeded, minResponseTime - now);
121
+ }
122
+ }
123
+ };
71
124
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
72
125
  this.datastore = { "system:presence": systemWorkspaceDatastore };
73
126
  this.workspaces.set("system:presence", systemWorkspace);
74
127
  this.targetedSignalSupport = this.runtime.supportedFeatures.has("submit_signals_v2");
128
+ // If audience member is removed, they won't need a broadcast response.
129
+ this.runtime.getAudience().on("removeMember", (clientId) => {
130
+ this.broadcastRequests.delete(clientId);
131
+ });
132
+ }
133
+ getInteractiveMembersExcludingSelf(selfClientId) {
134
+ const audience = this.runtime.getAudience();
135
+ const members = audience.getMembers();
136
+ const all = new Set();
137
+ const writers = new Set();
138
+ // Remove self (if present)
139
+ members.delete(selfClientId);
140
+ // Gather interactive client IDs
141
+ for (const [id, client] of members) {
142
+ if (client.details.capabilities.interactive) {
143
+ all.add(id);
144
+ if (client.mode === "write") {
145
+ writers.add(id);
146
+ }
147
+ }
148
+ }
149
+ return {
150
+ all,
151
+ writers,
152
+ };
75
153
  }
76
- joinSession(clientId) {
154
+ joinSession(selfClientId) {
155
+ const interactiveMembersExcludingSelf = this.getInteractiveMembersExcludingSelf(selfClientId);
156
+ // If there aren't any others connected, then this client must have
157
+ // complete information.
158
+ if (interactiveMembersExcludingSelf.all.size === 0) {
159
+ this.reasonForCompleteSnapshot = "alone";
160
+ // It would be possible to return at this time and skip ClientJoin
161
+ // signal. Instead continue in case audience information is
162
+ // inaccurate. This client might temporarily erroneously believe it
163
+ // has complete information, but the other(s) should respond to
164
+ // ClientJoin soon rectifying that and covering for bad incomplete
165
+ // responses this client sent in the meantime.
166
+ }
77
167
  // Broadcast join message to all clients
78
- const updateProviders = [...this.runtime.getQuorum().getMembers().keys()].filter((quorumClientId) => quorumClientId !== clientId);
168
+ // Select primary update providers
169
+ // Use write members if any, then fallback to read-only members.
170
+ const updateProviders = [
171
+ ...(interactiveMembersExcludingSelf.writers.size > 0
172
+ ? interactiveMembersExcludingSelf.writers
173
+ : interactiveMembersExcludingSelf.all),
174
+ ];
79
175
  // Limit to three providers to prevent flooding the network.
80
176
  // If none respond, others present will (should) after a delay.
81
177
  if (updateProviders.length > 3) {
@@ -90,6 +186,18 @@ export class PresenceDatastoreManagerImpl {
90
186
  updateProviders,
91
187
  },
92
188
  });
189
+ this.logger?.sendTelemetryEvent({
190
+ eventName: "JoinRequested",
191
+ details: {
192
+ attendeeId: this.attendeeId,
193
+ connectionId: selfClientId,
194
+ // Empty updateProviders is indicative of join when alone.
195
+ updateProviders: JSON.stringify(updateProviders),
196
+ },
197
+ });
198
+ }
199
+ onDisconnected() {
200
+ delete this.reasonForCompleteSnapshot;
93
201
  }
94
202
  getWorkspace(internalWorkspaceAddress, requestedContent, controls) {
95
203
  const existing = this.workspaces.get(internalWorkspaceAddress);
@@ -126,9 +234,14 @@ export class PresenceDatastoreManagerImpl {
126
234
  * the send timer, other messages in the queue, the configured allowed latency, etc.
127
235
  */
128
236
  enqueueMessage(data, options) {
129
- // Merging the message with any queued messages effectively queues the message.
130
- // It is OK to queue all incoming messages as long as when we send, we send the queued data.
131
- this.queuedData = mergeGeneralDatastoreMessageContent(this.queuedData, data);
237
+ if (this.queuedData !== "sendAll") {
238
+ this.queuedData =
239
+ data === "sendAll"
240
+ ? "sendAll"
241
+ : // Merging the message with any queued messages effectively queues the message.
242
+ // It is OK to queue all incoming messages as long as when we send, we send the queued data.
243
+ mergeGeneralDatastoreMessageContent(this.queuedData, data);
244
+ }
132
245
  const { allowableUpdateLatencyMs } = options;
133
246
  const now = Date.now();
134
247
  const thisMessageDeadline = now + allowableUpdateLatencyMs;
@@ -136,20 +249,20 @@ export class PresenceDatastoreManagerImpl {
136
249
  // If the timer has not expired, we can short-circuit because the timer will fire
137
250
  // and cover this update. In other words, queuing this will be fast enough to
138
251
  // meet its deadline, because a timer is already scheduled to fire before its deadline.
139
- !this.timer.hasExpired() &&
252
+ !this.sendMessageTimer.hasExpired() &&
140
253
  // If the deadline for this message is later than the overall send deadline, then
141
254
  // we can exit early since a timer will take care of sending it.
142
- thisMessageDeadline >= this.timer.expireTime) {
255
+ thisMessageDeadline >= this.sendMessageTimer.expireTime) {
143
256
  return;
144
257
  }
145
258
  // Either we need to send this message immediately, or we need to schedule a timer
146
259
  // to fire at the send deadline that will take care of it.
147
- // Note that timeoutInMs === allowableUpdateLatency, but the calculation is done this way for clarity.
260
+ // Note that timeoutInMs === allowableUpdateLatencyMs, but the calculation is done this way for clarity.
148
261
  const timeoutInMs = thisMessageDeadline - now;
149
262
  const scheduleForLater = timeoutInMs > 0;
150
263
  if (scheduleForLater) {
151
264
  // Schedule the queued messages to be sent at the updateDeadline
152
- this.timer.setTimeout(this.sendQueuedMessage.bind(this), timeoutInMs);
265
+ this.sendMessageTimer.setTimeout(this.sendQueuedMessage.bind(this), timeoutInMs);
153
266
  }
154
267
  else {
155
268
  this.sendQueuedMessage();
@@ -159,7 +272,7 @@ export class PresenceDatastoreManagerImpl {
159
272
  * Send any queued signal immediately. Does nothing if no message is queued.
160
273
  */
161
274
  sendQueuedMessage() {
162
- this.timer.clearTimeout();
275
+ this.sendMessageTimer.clearTimeout();
163
276
  if (this.queuedData === undefined) {
164
277
  return;
165
278
  }
@@ -170,12 +283,16 @@ export class PresenceDatastoreManagerImpl {
170
283
  this.queuedData = undefined;
171
284
  return;
172
285
  }
286
+ if (this.queuedData === "sendAll") {
287
+ this.broadcastAllKnownState();
288
+ return;
289
+ }
173
290
  const clientConnectionId = this.runtime.getClientId();
174
291
  assert(clientConnectionId !== undefined, 0xa59 /* Client connected without clientId */);
175
292
  const currentClientToSessionValueState =
176
293
  // When connected, `clientToSessionId` must always have current connection entry.
177
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
178
294
  this.datastore["system:presence"].clientToSessionId[clientConnectionId];
295
+ assert(currentClientToSessionValueState !== undefined, "Client connection update missing");
179
296
  const newMessage = {
180
297
  sendTimestamp: Date.now(),
181
298
  avgLatency: this.averageLatency,
@@ -248,16 +365,50 @@ export class PresenceDatastoreManagerImpl {
248
365
  return valueData;
249
366
  }
250
367
  broadcastAllKnownState() {
368
+ const content = {
369
+ sendTimestamp: Date.now(),
370
+ avgLatency: this.averageLatency,
371
+ isComplete: true,
372
+ data: this.stripValidationMetadata(this.datastore),
373
+ };
374
+ const primaryRequestors = [];
375
+ const secondaryRequestors = [];
376
+ if (this.broadcastRequests.size > 0) {
377
+ content.joinResponseFor = [...this.broadcastRequests.keys()];
378
+ if (this.logger) {
379
+ // Build telemetry data
380
+ for (const [requestor, { responseOrder }] of this.broadcastRequests.entries()) {
381
+ if (responseOrder === undefined) {
382
+ primaryRequestors.push(requestor);
383
+ }
384
+ else {
385
+ secondaryRequestors.push([requestor, responseOrder]);
386
+ }
387
+ }
388
+ }
389
+ this.broadcastRequests.clear();
390
+ }
391
+ // This broadcast will satisfy all requests; clear any remaining timer.
392
+ this.broadcastRequestsTimer.clearTimeout();
393
+ this.sendMessageTimer.clearTimeout();
251
394
  this.runtime.submitSignal({
252
395
  type: datastoreUpdateMessageType,
253
- content: {
254
- sendTimestamp: Date.now(),
255
- avgLatency: this.averageLatency,
256
- isComplete: true,
257
- data: this.stripValidationMetadata(this.datastore),
258
- },
396
+ content,
259
397
  });
260
- this.refreshBroadcastRequested = false;
398
+ if (content.joinResponseFor) {
399
+ this.logger?.sendTelemetryEvent({
400
+ eventName: "JoinResponse",
401
+ details: {
402
+ type: "broadcastAll",
403
+ attendeeId: this.attendeeId,
404
+ connectionId: this.runtime.getClientId(),
405
+ primaryResponses: JSON.stringify(primaryRequestors),
406
+ secondaryResponses: JSON.stringify(secondaryRequestors),
407
+ },
408
+ });
409
+ }
410
+ // Sending all must account for anything queued before.
411
+ this.queuedData = undefined;
261
412
  }
262
413
  processSignal(message, local, optional) {
263
414
  const received = Date.now();
@@ -278,8 +429,11 @@ export class PresenceDatastoreManagerImpl {
278
429
  this.returnedMessages;
279
430
  return;
280
431
  }
432
+ const selfClientId = this.runtime.getClientId();
433
+ assert(selfClientId !== undefined, "Received signal without clientId");
281
434
  const timeModifier = received -
282
435
  (this.averageLatency + message.content.avgLatency + message.content.sendTimestamp);
436
+ const postUpdateActions = [];
283
437
  if (message.type === joinMessageType) {
284
438
  // It is possible for some signals to come in while client is not connected due
285
439
  // to how work is scheduled. If we are not connected, we can't respond to the
@@ -291,8 +445,48 @@ export class PresenceDatastoreManagerImpl {
291
445
  // connected.
292
446
  }
293
447
  else {
294
- if (message.content.isComplete) {
295
- this.refreshBroadcastRequested = false;
448
+ // Update join response requests that are now satisfied.
449
+ const joinResponseFor = message.content.joinResponseFor;
450
+ if (joinResponseFor) {
451
+ let justGainedCompleteSnapshot = false;
452
+ if (joinResponseFor.includes(selfClientId)) {
453
+ if (this.reasonForCompleteSnapshot) {
454
+ if (this.reasonForCompleteSnapshot === "alone") {
455
+ // No response was expected. This might happen when
456
+ // either cautionary ClientJoin signal is received
457
+ // by audience member that was unknown.
458
+ this.logger?.sendTelemetryEvent({
459
+ eventName: "JoinResponseWhenAlone",
460
+ details: {
461
+ attendeeId: this.attendeeId,
462
+ connectionId: this.runtime.getClientId(),
463
+ },
464
+ });
465
+ }
466
+ }
467
+ else {
468
+ // If we are the intended recipient of the join response,
469
+ // we can consider our knowledge complete and can respond
470
+ // to others join requests.
471
+ justGainedCompleteSnapshot = true;
472
+ }
473
+ this.reasonForCompleteSnapshot = "join response";
474
+ }
475
+ if (this.broadcastRequests.size > 0) {
476
+ for (const responseFor of joinResponseFor) {
477
+ this.broadcastRequests.delete(responseFor);
478
+ }
479
+ if (this.broadcastRequests.size === 0) {
480
+ // If no more requests are pending, clear any timer.
481
+ this.broadcastRequestsTimer.clearTimeout();
482
+ }
483
+ else if (justGainedCompleteSnapshot) {
484
+ // May or may not be time to respond to remaining requests.
485
+ // Clear the timer and recheck after processing.
486
+ this.broadcastRequestsTimer.clearTimeout();
487
+ postUpdateActions.push(this.sendJoinResponseIfStillNeeded);
488
+ }
489
+ }
296
490
  }
297
491
  // If the message requests an acknowledgement, we will send a targeted acknowledgement message back to just the requestor.
298
492
  if (message.content.acknowledgementId !== undefined) {
@@ -322,7 +516,6 @@ export class PresenceDatastoreManagerImpl {
322
516
  const internalWorkspaceType = internalWorkspaceTypes[prefix] ?? "Unknown";
323
517
  this.events.emit("workspaceActivated", publicWorkspaceAddress, internalWorkspaceType);
324
518
  }
325
- const postUpdateActions = [];
326
519
  // While the system workspace is processed here too, it is declared as
327
520
  // conforming to the general schema. So drop its override.
328
521
  const data = message.content.data;
@@ -358,67 +551,86 @@ export class PresenceDatastoreManagerImpl {
358
551
  * correlation with other telemetry where it is often called just `clientId`.
359
552
  */
360
553
  prepareJoinResponse(updateProviders, requestor) {
361
- this.refreshBroadcastRequested = true;
362
554
  // We must be connected to receive this message, so clientId should be defined.
363
- // If it isn't then, not really a problem; just won't be in provider or quorum list.
555
+ // If it isn't then, not really a problem; just won't be in provider or audience list.
364
556
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
365
- const clientId = this.runtime.getClientId();
366
- // const requestor = message.clientId;
367
- if (updateProviders.includes(clientId)) {
368
- // Send all current state to the new client
369
- this.broadcastAllKnownState();
370
- this.logger?.sendTelemetryEvent({
371
- eventName: "JoinResponse",
372
- details: {
373
- type: "broadcastAll",
374
- requestor,
375
- role: "primary",
376
- },
377
- });
378
- }
379
- else {
557
+ const selfClientId = this.runtime.getClientId();
558
+ let joinResponseDelayMs = broadcastJoinResponseDelaysMs.namedResponder;
559
+ let relativeResponseOrder;
560
+ if (!updateProviders.includes(selfClientId)) {
380
561
  // Schedule a broadcast to the new client after a delay only to send if
381
- // another broadcast hasn't been seen in the meantime. The delay is based
382
- // on the position in the quorum list. It doesn't have to be a stable
383
- // list across all clients. We need something to provide suggested order
384
- // to prevent a flood of broadcasts.
385
- let relativeResponseOrder;
562
+ // another broadcast satisfying the request hasn't been seen in the
563
+ // meantime. The delay is based on the position in the quorum list. It
564
+ // doesn't have to be a stable list across all clients. We need
565
+ // something to provide suggested order to prevent a flood of broadcasts.
386
566
  const quorumMembers = this.runtime.getQuorum().getMembers();
387
- const self = quorumMembers.get(clientId);
567
+ const self = quorumMembers.get(selfClientId);
388
568
  if (self) {
389
569
  // Compute order quorum join order (indicated by sequenceNumber)
390
570
  relativeResponseOrder = 0;
391
- for (const { sequenceNumber } of quorumMembers.values()) {
392
- if (sequenceNumber < self.sequenceNumber) {
571
+ for (const { client, sequenceNumber } of quorumMembers.values()) {
572
+ if (sequenceNumber < self.sequenceNumber &&
573
+ client.details.capabilities.interactive) {
393
574
  relativeResponseOrder++;
394
575
  }
395
576
  }
396
577
  }
397
578
  else {
398
579
  // Order past quorum members + arbitrary additional offset up to 10
399
- relativeResponseOrder = quorumMembers.size + Math.random() * 10;
400
- }
401
- // These numbers have been chosen arbitrarily to start with.
402
- // 20 is minimum wait time, 20 is the additional wait time per provider
403
- // given an chance before us with named providers given more time.
404
- const waitTime = 20 + 20 * (3 * updateProviders.length + relativeResponseOrder);
405
- setTimeout(() => {
406
- // Make sure a broadcast is still needed and we are currently connected.
407
- // If not connected, nothing we can do.
408
- if (this.refreshBroadcastRequested &&
409
- this.runtime.getJoinedStatus() !== "disconnected") {
410
- this.broadcastAllKnownState();
411
- this.logger?.sendTelemetryEvent({
412
- eventName: "JoinResponse",
413
- details: {
414
- type: "broadcastAll",
415
- requestor,
416
- role: "secondary",
417
- order: relativeResponseOrder,
418
- },
419
- });
580
+ let possibleQuorumRespondents = 0;
581
+ for (const { client } of quorumMembers.values()) {
582
+ if (client.details.capabilities.interactive) {
583
+ possibleQuorumRespondents++;
584
+ }
420
585
  }
421
- }, waitTime);
586
+ relativeResponseOrder = possibleQuorumRespondents + Math.random() * 10;
587
+ }
588
+ // When not named to provide update, wait an additional amount
589
+ // of time for those named or others to respond.
590
+ joinResponseDelayMs +=
591
+ broadcastJoinResponseDelaysMs.backupResponderIncrement *
592
+ (3 * updateProviders.length + relativeResponseOrder);
593
+ }
594
+ // Add the requestor to the list of clients that will receive the broadcast.
595
+ const deadlineTime = Date.now() + joinResponseDelayMs;
596
+ this.broadcastRequests.set(requestor, {
597
+ deadlineTime,
598
+ responseOrder: relativeResponseOrder,
599
+ });
600
+ if (!this.reasonForCompleteSnapshot) {
601
+ // Check if requestor count meets or exceeds count of other audience
602
+ // members indicating that we effectively have a complete snapshot
603
+ // (once the current message being processed is processed).
604
+ const interactiveMembersExcludingSelf = this.getInteractiveMembersExcludingSelf(selfClientId);
605
+ if (this.broadcastRequests.size >= interactiveMembersExcludingSelf.all.size) {
606
+ // Note that no action is taken here specifically.
607
+ // We want action to be queued so that it takes place after
608
+ // current message is completely processed. All of the actions
609
+ // below should be delayed (not immediate).
610
+ this.reasonForCompleteSnapshot = "full requests";
611
+ }
612
+ }
613
+ // Check if capable of full primary response. If requested to provide
614
+ // primary response, but do not yet have complete snapshot, we need to
615
+ // delay a full response, until we think we have complete snapshot. In
616
+ // the meantime we will send partial updates as usual.
617
+ if (this.reasonForCompleteSnapshot && updateProviders.includes(selfClientId)) {
618
+ // Use regular message queue to handle timing of the broadcast.
619
+ // Any more immediate broadcasts will accelerate the response time.
620
+ // As a primary responder, it is expected that broadcast will happen and
621
+ // using the regular queue allows other updates to avoid merge work.
622
+ this.enqueueMessage("sendAll", {
623
+ allowableUpdateLatencyMs: joinResponseDelayMs,
624
+ });
625
+ }
626
+ else {
627
+ // Check if there isn't already a timer scheduled to send a join
628
+ // response with in this request's deadline.
629
+ if (this.broadcastRequestsTimer.hasExpired() ||
630
+ deadlineTime < this.broadcastRequestsTimer.expireTime) {
631
+ // Set or update the timer.
632
+ this.broadcastRequestsTimer.setTimeout(this.sendJoinResponseIfStillNeeded, joinResponseDelayMs);
633
+ }
422
634
  }
423
635
  }
424
636
  }