@fluidframework/presence 2.70.0-361248 → 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.
@@ -30,10 +30,32 @@ export declare function isValueDirectory<T>(obj: ValidatableValueDirectory<T> |
30
30
  */
31
31
  export interface PresenceDatastoreManager {
32
32
  joinSession(clientId: ClientConnectionId): void;
33
+ onDisconnected(): void;
33
34
  getWorkspace<TSchema extends StatesWorkspaceSchema>(internalWorkspaceAddress: `s:${WorkspaceAddress}`, requestedContent: TSchema, controls?: BroadcastControlSettings): StatesWorkspace<TSchema>;
34
35
  getWorkspace<TSchema extends NotificationsWorkspaceSchema>(internalWorkspaceAddress: `n:${WorkspaceAddress}`, requestedContent: TSchema): NotificationsWorkspace<TSchema>;
35
36
  processSignal(message: InboundExtensionMessage<SignalMessages>, local: boolean, optional: boolean): void;
36
37
  }
38
+ /**
39
+ * Delays used for broadcasting join responses to clients.
40
+ *
41
+ * @remarks
42
+ * Exported for test coordination.
43
+ * These could be made customizable in the future to accommodate different
44
+ * session configurations.
45
+ */
46
+ export declare const broadcastJoinResponseDelaysMs: {
47
+ /**
48
+ * The delay in milliseconds before a join response is sent to any client.
49
+ * This is used to accumulate other join response requests and reduce
50
+ * network traffic.
51
+ */
52
+ readonly namedResponder: 200;
53
+ /**
54
+ * The additional delay in milliseconds a backup responder waits before sending
55
+ * a join response to allow others to respond first.
56
+ */
57
+ readonly backupResponderIncrement: 40;
58
+ };
37
59
  /**
38
60
  * Manages singleton datastore for all Presence.
39
61
  */
@@ -46,15 +68,47 @@ export declare class PresenceDatastoreManagerImpl implements PresenceDatastoreMa
46
68
  private readonly datastore;
47
69
  private averageLatency;
48
70
  private returnedMessages;
49
- private refreshBroadcastRequested;
50
- private readonly timer;
71
+ private readonly sendMessageTimer;
51
72
  private readonly workspaces;
52
73
  private readonly targetedSignalSupport;
74
+ /**
75
+ * Tracks whether this client has complete snapshot level knowledge and
76
+ * how that determination was reached.
77
+ * - "alone": no other audience members detected at join
78
+ * - "join response": another client has responded to our join request
79
+ * - "full requests": all others have requested response from us
80
+ *
81
+ * @remarks
82
+ * Only applies when not using targeted join responses.
83
+ *
84
+ * Without a complete snapshot, we cannot fully onboard any other clients.
85
+ * One exception to this is if this client is the only participant in the
86
+ * session. In such a case, there is no one to respond to the join request.
87
+ * Another exception is multiple clients attempting to join at the same
88
+ * time and thus expecting that someone has full knowledge, yet none have
89
+ * received a complete update to think they are qualified to respond.
90
+ * Generically if the number of outstanding requestors meets or exceeds the
91
+ * count of other audience members, then we can consider the snapshot
92
+ * complete (as all will have provided their own complete information in
93
+ * their join responses).
94
+ */
95
+ private reasonForCompleteSnapshot?;
96
+ /**
97
+ * Map of outstanding broadcast (join response) requests.
98
+ */
99
+ private readonly broadcastRequests;
100
+ /**
101
+ * Timer for managing broadcast (join response) request timing.
102
+ */
103
+ private readonly broadcastRequestsTimer;
53
104
  constructor(attendeeId: AttendeeId, runtime: IEphemeralRuntime, logger: ITelemetryLoggerExt | undefined, events: IEmitter<PresenceEvents>, presence: Presence, systemWorkspaceDatastore: SystemWorkspaceDatastore, systemWorkspace: AnyWorkspaceEntry<StatesWorkspaceSchema>);
54
- joinSession(clientId: ClientConnectionId): void;
105
+ private getInteractiveMembersExcludingSelf;
106
+ joinSession(selfClientId: ClientConnectionId): void;
107
+ onDisconnected(): void;
55
108
  getWorkspace<TSchema extends StatesWorkspaceSchema>(internalWorkspaceAddress: InternalWorkspaceAddress, requestedContent: TSchema, controls?: BroadcastControlSettings): AnyWorkspace<TSchema>;
56
109
  /**
57
- * The combined contents of all queued updates. Will be undefined when no messages are queued.
110
+ * The combined contents of all queued updates. Will be `"sendAll"` when a
111
+ * full broadcast is pending or `undefined` when no messages are queued.
58
112
  */
59
113
  private queuedData;
60
114
  /**
@@ -77,6 +131,11 @@ export declare class PresenceDatastoreManagerImpl implements PresenceDatastoreMa
77
131
  private stripValidationFromValueData;
78
132
  private broadcastAllKnownState;
79
133
  processSignal(message: InboundExtensionMessage<SignalMessages>, local: boolean, optional: boolean): void;
134
+ /**
135
+ * Broadcasts a join response (complete datastore update message)
136
+ * if there is an outstanding join response request.
137
+ */
138
+ private readonly sendJoinResponseIfStillNeeded;
80
139
  /**
81
140
  * Handles responding to another client joining the session.
82
141
  *
@@ -1 +1 @@
1
- {"version":3,"file":"presenceDatastoreManager.d.ts","sourceRoot":"","sources":["../src/presenceDatastoreManager.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,wDAAwD,CAAC;AACtG,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,0CAA0C,CAAC;AAEzE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0CAA0C,CAAC;AAEpF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAEvE,OAAO,KAAK,EACX,iBAAiB,EAEjB,wBAAwB,EACxB,yBAAyB,EAEzB,MAAM,oBAAoB,CAAC;AAE5B,OAAO,KAAK,EACX,UAAU,EACV,yBAAyB,IAAI,QAAQ,EACrC,cAAc,EACd,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAGX,sBAAsB,EAEtB,MAAM,qBAAqB,CAAC;AAM7B,OAAO,KAAK,EAKX,wBAAwB,EAExB,cAAc,EAEd,MAAM,eAAe,CAAC;AAMvB,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAErE,OAAO,KAAK,EACX,YAAY,EACZ,sBAAsB,EACtB,4BAA4B,EAC5B,eAAe,EACf,qBAAqB,EACrB,gBAAgB,EAChB,MAAM,YAAY,CAAC;AAEpB,UAAU,iBAAiB,CAAC,OAAO,SAAS,qBAAqB;IAChE,MAAM,EAAE,YAAY,CAAC,OAAO,CAAC,CAAC;IAC9B,QAAQ,EAAE,sBAAsB,CAAC;CACjC;AA0BD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EACjC,GAAG,EAAE,yBAAyB,CAAC,CAAC,CAAC,GAAG,wBAAwB,CAAC,CAAC,CAAC,GAC7D,GAAG,IAAI,yBAAyB,CAAC,CAAC,CAAC,CAErC;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACxC,WAAW,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI,CAAC;IAChD,YAAY,CAAC,OAAO,SAAS,qBAAqB,EACjD,wBAAwB,EAAE,KAAK,gBAAgB,EAAE,EACjD,gBAAgB,EAAE,OAAO,EACzB,QAAQ,CAAC,EAAE,wBAAwB,GACjC,eAAe,CAAC,OAAO,CAAC,CAAC;IAC5B,YAAY,CAAC,OAAO,SAAS,4BAA4B,EACxD,wBAAwB,EAAE,KAAK,gBAAgB,EAAE,EACjD,gBAAgB,EAAE,OAAO,GACvB,sBAAsB,CAAC,OAAO,CAAC,CAAC;IACnC,aAAa,CACZ,OAAO,EAAE,uBAAuB,CAAC,cAAc,CAAC,EAChD,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,OAAO,GACf,IAAI,CAAC;CACR;AAqCD;;GAEG;AACH,qBAAa,4BAA6B,YAAW,wBAAwB;IAU3E,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAb1B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoB;IAC9C,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,yBAAyB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAsB;IAC5C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA+D;IAC1F,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAU;gBAG9B,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,iBAAiB,EAC1B,MAAM,EAAE,mBAAmB,GAAG,SAAS,EACvC,MAAM,EAAE,QAAQ,CAAC,cAAc,CAAC,EAChC,QAAQ,EAAE,QAAQ,EACnC,wBAAwB,EAAE,wBAAwB,EAClD,eAAe,EAAE,iBAAiB,CAAC,qBAAqB,CAAC;IAQnD,WAAW,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI;IAqB/C,YAAY,CAAC,OAAO,SAAS,qBAAqB,EACxD,wBAAwB,EAAE,wBAAwB,EAClD,gBAAgB,EAAE,OAAO,EACzB,QAAQ,CAAC,EAAE,wBAAwB,GACjC,YAAY,CAAC,OAAO,CAAC;IAiDxB;;OAEG;IACH,OAAO,CAAC,UAAU,CAA6C;IAE/D;;;OAGG;IACH,OAAO,CAAC,cAAc;IAuCtB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IA2CzB;;;OAGG;IACH,OAAO,CAAC,uBAAuB;IA6B/B;;OAEG;IACH,OAAO,CAAC,4BAA4B;IAkCpC,OAAO,CAAC,sBAAsB;IAavB,aAAa,CACnB,OAAO,EAAE,uBAAuB,CAAC,cAAc,CAAC,EAChD,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,OAAO,GACf,IAAI;IA+GP;;;;;;;;;;OAUG;IACH,OAAO,CAAC,mBAAmB;CAmE3B"}
1
+ {"version":3,"file":"presenceDatastoreManager.d.ts","sourceRoot":"","sources":["../src/presenceDatastoreManager.ts"],"names":[],"mappings":"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"}
@@ -4,7 +4,7 @@
4
4
  * Licensed under the MIT License.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.PresenceDatastoreManagerImpl = exports.isValueDirectory = void 0;
7
+ exports.PresenceDatastoreManagerImpl = exports.broadcastJoinResponseDelaysMs = exports.isValueDirectory = void 0;
8
8
  const internal_1 = require("@fluidframework/core-utils/internal");
9
9
  const internalUtils_js_1 = require("./internalUtils.js");
10
10
  const presenceStates_js_1 = require("./presenceStates.js");
@@ -57,6 +57,27 @@ function mergeGeneralDatastoreMessageContent(base, newData) {
57
57
  }
58
58
  return queueDatastore;
59
59
  }
60
+ /**
61
+ * Delays used for broadcasting join responses to clients.
62
+ *
63
+ * @remarks
64
+ * Exported for test coordination.
65
+ * These could be made customizable in the future to accommodate different
66
+ * session configurations.
67
+ */
68
+ exports.broadcastJoinResponseDelaysMs = {
69
+ /**
70
+ * The delay in milliseconds before a join response is sent to any client.
71
+ * This is used to accumulate other join response requests and reduce
72
+ * network traffic.
73
+ */
74
+ namedResponder: 200,
75
+ /**
76
+ * The additional delay in milliseconds a backup responder waits before sending
77
+ * a join response to allow others to respond first.
78
+ */
79
+ backupResponderIncrement: 40,
80
+ };
60
81
  /**
61
82
  * Manages singleton datastore for all Presence.
62
83
  */
@@ -69,17 +90,92 @@ class PresenceDatastoreManagerImpl {
69
90
  this.presence = presence;
70
91
  this.averageLatency = 0;
71
92
  this.returnedMessages = 0;
72
- this.refreshBroadcastRequested = false;
73
- this.timer = new timerManager_js_1.TimerManager();
93
+ this.sendMessageTimer = new timerManager_js_1.TimerManager();
74
94
  this.workspaces = new Map();
95
+ /**
96
+ * Map of outstanding broadcast (join response) requests.
97
+ */
98
+ this.broadcastRequests = new Map();
99
+ /**
100
+ * Timer for managing broadcast (join response) request timing.
101
+ */
102
+ this.broadcastRequestsTimer = new timerManager_js_1.TimerManager();
103
+ /**
104
+ * Broadcasts a join response (complete datastore update message)
105
+ * if there is an outstanding join response request.
106
+ */
107
+ this.sendJoinResponseIfStillNeeded = () => {
108
+ // Make sure we are currently connected and a broadcast is still needed.
109
+ // If not connected, nothing we can do.
110
+ if (this.runtime.getJoinedStatus() !== "disconnected" && this.broadcastRequests.size > 0) {
111
+ // Confirm that of remaining requests, now is the time to respond.
112
+ const now = Date.now();
113
+ let minResponseTime = Number.POSITIVE_INFINITY;
114
+ for (const { deadlineTime } of this.broadcastRequests.values()) {
115
+ minResponseTime = Math.min(minResponseTime, deadlineTime);
116
+ }
117
+ if (minResponseTime <= now) {
118
+ if (this.reasonForCompleteSnapshot) {
119
+ this.broadcastAllKnownState();
120
+ }
121
+ }
122
+ else {
123
+ // No response needed yet - schedule a later attempt
124
+ this.broadcastRequestsTimer.setTimeout(this.sendJoinResponseIfStillNeeded, minResponseTime - now);
125
+ }
126
+ }
127
+ };
75
128
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
76
129
  this.datastore = { "system:presence": systemWorkspaceDatastore };
77
130
  this.workspaces.set("system:presence", systemWorkspace);
78
131
  this.targetedSignalSupport = this.runtime.supportedFeatures.has("submit_signals_v2");
132
+ // If audience member is removed, they won't need a broadcast response.
133
+ this.runtime.getAudience().on("removeMember", (clientId) => {
134
+ this.broadcastRequests.delete(clientId);
135
+ });
136
+ }
137
+ getInteractiveMembersExcludingSelf(selfClientId) {
138
+ const audience = this.runtime.getAudience();
139
+ const members = audience.getMembers();
140
+ const all = new Set();
141
+ const writers = new Set();
142
+ // Remove self (if present)
143
+ members.delete(selfClientId);
144
+ // Gather interactive client IDs
145
+ for (const [id, client] of members) {
146
+ if (client.details.capabilities.interactive) {
147
+ all.add(id);
148
+ if (client.mode === "write") {
149
+ writers.add(id);
150
+ }
151
+ }
152
+ }
153
+ return {
154
+ all,
155
+ writers,
156
+ };
79
157
  }
80
- joinSession(clientId) {
158
+ joinSession(selfClientId) {
159
+ const interactiveMembersExcludingSelf = this.getInteractiveMembersExcludingSelf(selfClientId);
160
+ // If there aren't any others connected, then this client must have
161
+ // complete information.
162
+ if (interactiveMembersExcludingSelf.all.size === 0) {
163
+ this.reasonForCompleteSnapshot = "alone";
164
+ // It would be possible to return at this time and skip ClientJoin
165
+ // signal. Instead continue in case audience information is
166
+ // inaccurate. This client might temporarily erroneously believe it
167
+ // has complete information, but the other(s) should respond to
168
+ // ClientJoin soon rectifying that and covering for bad incomplete
169
+ // responses this client sent in the meantime.
170
+ }
81
171
  // Broadcast join message to all clients
82
- const updateProviders = [...this.runtime.getQuorum().getMembers().keys()].filter((quorumClientId) => quorumClientId !== clientId);
172
+ // Select primary update providers
173
+ // Use write members if any, then fallback to read-only members.
174
+ const updateProviders = [
175
+ ...(interactiveMembersExcludingSelf.writers.size > 0
176
+ ? interactiveMembersExcludingSelf.writers
177
+ : interactiveMembersExcludingSelf.all),
178
+ ];
83
179
  // Limit to three providers to prevent flooding the network.
84
180
  // If none respond, others present will (should) after a delay.
85
181
  if (updateProviders.length > 3) {
@@ -94,6 +190,18 @@ class PresenceDatastoreManagerImpl {
94
190
  updateProviders,
95
191
  },
96
192
  });
193
+ this.logger?.sendTelemetryEvent({
194
+ eventName: "JoinRequested",
195
+ details: {
196
+ attendeeId: this.attendeeId,
197
+ connectionId: selfClientId,
198
+ // Empty updateProviders is indicative of join when alone.
199
+ updateProviders: JSON.stringify(updateProviders),
200
+ },
201
+ });
202
+ }
203
+ onDisconnected() {
204
+ delete this.reasonForCompleteSnapshot;
97
205
  }
98
206
  getWorkspace(internalWorkspaceAddress, requestedContent, controls) {
99
207
  const existing = this.workspaces.get(internalWorkspaceAddress);
@@ -130,9 +238,14 @@ class PresenceDatastoreManagerImpl {
130
238
  * the send timer, other messages in the queue, the configured allowed latency, etc.
131
239
  */
132
240
  enqueueMessage(data, options) {
133
- // Merging the message with any queued messages effectively queues the message.
134
- // It is OK to queue all incoming messages as long as when we send, we send the queued data.
135
- this.queuedData = mergeGeneralDatastoreMessageContent(this.queuedData, data);
241
+ if (this.queuedData !== "sendAll") {
242
+ this.queuedData =
243
+ data === "sendAll"
244
+ ? "sendAll"
245
+ : // Merging the message with any queued messages effectively queues the message.
246
+ // It is OK to queue all incoming messages as long as when we send, we send the queued data.
247
+ mergeGeneralDatastoreMessageContent(this.queuedData, data);
248
+ }
136
249
  const { allowableUpdateLatencyMs } = options;
137
250
  const now = Date.now();
138
251
  const thisMessageDeadline = now + allowableUpdateLatencyMs;
@@ -140,20 +253,20 @@ class PresenceDatastoreManagerImpl {
140
253
  // If the timer has not expired, we can short-circuit because the timer will fire
141
254
  // and cover this update. In other words, queuing this will be fast enough to
142
255
  // meet its deadline, because a timer is already scheduled to fire before its deadline.
143
- !this.timer.hasExpired() &&
256
+ !this.sendMessageTimer.hasExpired() &&
144
257
  // If the deadline for this message is later than the overall send deadline, then
145
258
  // we can exit early since a timer will take care of sending it.
146
- thisMessageDeadline >= this.timer.expireTime) {
259
+ thisMessageDeadline >= this.sendMessageTimer.expireTime) {
147
260
  return;
148
261
  }
149
262
  // Either we need to send this message immediately, or we need to schedule a timer
150
263
  // to fire at the send deadline that will take care of it.
151
- // Note that timeoutInMs === allowableUpdateLatency, but the calculation is done this way for clarity.
264
+ // Note that timeoutInMs === allowableUpdateLatencyMs, but the calculation is done this way for clarity.
152
265
  const timeoutInMs = thisMessageDeadline - now;
153
266
  const scheduleForLater = timeoutInMs > 0;
154
267
  if (scheduleForLater) {
155
268
  // Schedule the queued messages to be sent at the updateDeadline
156
- this.timer.setTimeout(this.sendQueuedMessage.bind(this), timeoutInMs);
269
+ this.sendMessageTimer.setTimeout(this.sendQueuedMessage.bind(this), timeoutInMs);
157
270
  }
158
271
  else {
159
272
  this.sendQueuedMessage();
@@ -163,7 +276,7 @@ class PresenceDatastoreManagerImpl {
163
276
  * Send any queued signal immediately. Does nothing if no message is queued.
164
277
  */
165
278
  sendQueuedMessage() {
166
- this.timer.clearTimeout();
279
+ this.sendMessageTimer.clearTimeout();
167
280
  if (this.queuedData === undefined) {
168
281
  return;
169
282
  }
@@ -174,12 +287,16 @@ class PresenceDatastoreManagerImpl {
174
287
  this.queuedData = undefined;
175
288
  return;
176
289
  }
290
+ if (this.queuedData === "sendAll") {
291
+ this.broadcastAllKnownState();
292
+ return;
293
+ }
177
294
  const clientConnectionId = this.runtime.getClientId();
178
295
  (0, internal_1.assert)(clientConnectionId !== undefined, 0xa59 /* Client connected without clientId */);
179
296
  const currentClientToSessionValueState =
180
297
  // When connected, `clientToSessionId` must always have current connection entry.
181
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
182
298
  this.datastore["system:presence"].clientToSessionId[clientConnectionId];
299
+ (0, internal_1.assert)(currentClientToSessionValueState !== undefined, "Client connection update missing");
183
300
  const newMessage = {
184
301
  sendTimestamp: Date.now(),
185
302
  avgLatency: this.averageLatency,
@@ -252,16 +369,50 @@ class PresenceDatastoreManagerImpl {
252
369
  return valueData;
253
370
  }
254
371
  broadcastAllKnownState() {
372
+ const content = {
373
+ sendTimestamp: Date.now(),
374
+ avgLatency: this.averageLatency,
375
+ isComplete: true,
376
+ data: this.stripValidationMetadata(this.datastore),
377
+ };
378
+ const primaryRequestors = [];
379
+ const secondaryRequestors = [];
380
+ if (this.broadcastRequests.size > 0) {
381
+ content.joinResponseFor = [...this.broadcastRequests.keys()];
382
+ if (this.logger) {
383
+ // Build telemetry data
384
+ for (const [requestor, { responseOrder }] of this.broadcastRequests.entries()) {
385
+ if (responseOrder === undefined) {
386
+ primaryRequestors.push(requestor);
387
+ }
388
+ else {
389
+ secondaryRequestors.push([requestor, responseOrder]);
390
+ }
391
+ }
392
+ }
393
+ this.broadcastRequests.clear();
394
+ }
395
+ // This broadcast will satisfy all requests; clear any remaining timer.
396
+ this.broadcastRequestsTimer.clearTimeout();
397
+ this.sendMessageTimer.clearTimeout();
255
398
  this.runtime.submitSignal({
256
399
  type: protocol_js_1.datastoreUpdateMessageType,
257
- content: {
258
- sendTimestamp: Date.now(),
259
- avgLatency: this.averageLatency,
260
- isComplete: true,
261
- data: this.stripValidationMetadata(this.datastore),
262
- },
400
+ content,
263
401
  });
264
- this.refreshBroadcastRequested = false;
402
+ if (content.joinResponseFor) {
403
+ this.logger?.sendTelemetryEvent({
404
+ eventName: "JoinResponse",
405
+ details: {
406
+ type: "broadcastAll",
407
+ attendeeId: this.attendeeId,
408
+ connectionId: this.runtime.getClientId(),
409
+ primaryResponses: JSON.stringify(primaryRequestors),
410
+ secondaryResponses: JSON.stringify(secondaryRequestors),
411
+ },
412
+ });
413
+ }
414
+ // Sending all must account for anything queued before.
415
+ this.queuedData = undefined;
265
416
  }
266
417
  processSignal(message, local, optional) {
267
418
  const received = Date.now();
@@ -282,8 +433,11 @@ class PresenceDatastoreManagerImpl {
282
433
  this.returnedMessages;
283
434
  return;
284
435
  }
436
+ const selfClientId = this.runtime.getClientId();
437
+ (0, internal_1.assert)(selfClientId !== undefined, "Received signal without clientId");
285
438
  const timeModifier = received -
286
439
  (this.averageLatency + message.content.avgLatency + message.content.sendTimestamp);
440
+ const postUpdateActions = [];
287
441
  if (message.type === protocol_js_1.joinMessageType) {
288
442
  // It is possible for some signals to come in while client is not connected due
289
443
  // to how work is scheduled. If we are not connected, we can't respond to the
@@ -295,8 +449,48 @@ class PresenceDatastoreManagerImpl {
295
449
  // connected.
296
450
  }
297
451
  else {
298
- if (message.content.isComplete) {
299
- this.refreshBroadcastRequested = false;
452
+ // Update join response requests that are now satisfied.
453
+ const joinResponseFor = message.content.joinResponseFor;
454
+ if (joinResponseFor) {
455
+ let justGainedCompleteSnapshot = false;
456
+ if (joinResponseFor.includes(selfClientId)) {
457
+ if (this.reasonForCompleteSnapshot) {
458
+ if (this.reasonForCompleteSnapshot === "alone") {
459
+ // No response was expected. This might happen when
460
+ // either cautionary ClientJoin signal is received
461
+ // by audience member that was unknown.
462
+ this.logger?.sendTelemetryEvent({
463
+ eventName: "JoinResponseWhenAlone",
464
+ details: {
465
+ attendeeId: this.attendeeId,
466
+ connectionId: this.runtime.getClientId(),
467
+ },
468
+ });
469
+ }
470
+ }
471
+ else {
472
+ // If we are the intended recipient of the join response,
473
+ // we can consider our knowledge complete and can respond
474
+ // to others join requests.
475
+ justGainedCompleteSnapshot = true;
476
+ }
477
+ this.reasonForCompleteSnapshot = "join response";
478
+ }
479
+ if (this.broadcastRequests.size > 0) {
480
+ for (const responseFor of joinResponseFor) {
481
+ this.broadcastRequests.delete(responseFor);
482
+ }
483
+ if (this.broadcastRequests.size === 0) {
484
+ // If no more requests are pending, clear any timer.
485
+ this.broadcastRequestsTimer.clearTimeout();
486
+ }
487
+ else if (justGainedCompleteSnapshot) {
488
+ // May or may not be time to respond to remaining requests.
489
+ // Clear the timer and recheck after processing.
490
+ this.broadcastRequestsTimer.clearTimeout();
491
+ postUpdateActions.push(this.sendJoinResponseIfStillNeeded);
492
+ }
493
+ }
300
494
  }
301
495
  // If the message requests an acknowledgement, we will send a targeted acknowledgement message back to just the requestor.
302
496
  if (message.content.acknowledgementId !== undefined) {
@@ -326,7 +520,6 @@ class PresenceDatastoreManagerImpl {
326
520
  const internalWorkspaceType = internalWorkspaceTypes[prefix] ?? "Unknown";
327
521
  this.events.emit("workspaceActivated", publicWorkspaceAddress, internalWorkspaceType);
328
522
  }
329
- const postUpdateActions = [];
330
523
  // While the system workspace is processed here too, it is declared as
331
524
  // conforming to the general schema. So drop its override.
332
525
  const data = message.content.data;
@@ -362,67 +555,86 @@ class PresenceDatastoreManagerImpl {
362
555
  * correlation with other telemetry where it is often called just `clientId`.
363
556
  */
364
557
  prepareJoinResponse(updateProviders, requestor) {
365
- this.refreshBroadcastRequested = true;
366
558
  // We must be connected to receive this message, so clientId should be defined.
367
- // If it isn't then, not really a problem; just won't be in provider or quorum list.
559
+ // If it isn't then, not really a problem; just won't be in provider or audience list.
368
560
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
369
- const clientId = this.runtime.getClientId();
370
- // const requestor = message.clientId;
371
- if (updateProviders.includes(clientId)) {
372
- // Send all current state to the new client
373
- this.broadcastAllKnownState();
374
- this.logger?.sendTelemetryEvent({
375
- eventName: "JoinResponse",
376
- details: {
377
- type: "broadcastAll",
378
- requestor,
379
- role: "primary",
380
- },
381
- });
382
- }
383
- else {
561
+ const selfClientId = this.runtime.getClientId();
562
+ let joinResponseDelayMs = exports.broadcastJoinResponseDelaysMs.namedResponder;
563
+ let relativeResponseOrder;
564
+ if (!updateProviders.includes(selfClientId)) {
384
565
  // Schedule a broadcast to the new client after a delay only to send if
385
- // another broadcast hasn't been seen in the meantime. The delay is based
386
- // on the position in the quorum list. It doesn't have to be a stable
387
- // list across all clients. We need something to provide suggested order
388
- // to prevent a flood of broadcasts.
389
- let relativeResponseOrder;
566
+ // another broadcast satisfying the request hasn't been seen in the
567
+ // meantime. The delay is based on the position in the quorum list. It
568
+ // doesn't have to be a stable list across all clients. We need
569
+ // something to provide suggested order to prevent a flood of broadcasts.
390
570
  const quorumMembers = this.runtime.getQuorum().getMembers();
391
- const self = quorumMembers.get(clientId);
571
+ const self = quorumMembers.get(selfClientId);
392
572
  if (self) {
393
573
  // Compute order quorum join order (indicated by sequenceNumber)
394
574
  relativeResponseOrder = 0;
395
- for (const { sequenceNumber } of quorumMembers.values()) {
396
- if (sequenceNumber < self.sequenceNumber) {
575
+ for (const { client, sequenceNumber } of quorumMembers.values()) {
576
+ if (sequenceNumber < self.sequenceNumber &&
577
+ client.details.capabilities.interactive) {
397
578
  relativeResponseOrder++;
398
579
  }
399
580
  }
400
581
  }
401
582
  else {
402
583
  // Order past quorum members + arbitrary additional offset up to 10
403
- relativeResponseOrder = quorumMembers.size + Math.random() * 10;
404
- }
405
- // These numbers have been chosen arbitrarily to start with.
406
- // 20 is minimum wait time, 20 is the additional wait time per provider
407
- // given an chance before us with named providers given more time.
408
- const waitTime = 20 + 20 * (3 * updateProviders.length + relativeResponseOrder);
409
- setTimeout(() => {
410
- // Make sure a broadcast is still needed and we are currently connected.
411
- // If not connected, nothing we can do.
412
- if (this.refreshBroadcastRequested &&
413
- this.runtime.getJoinedStatus() !== "disconnected") {
414
- this.broadcastAllKnownState();
415
- this.logger?.sendTelemetryEvent({
416
- eventName: "JoinResponse",
417
- details: {
418
- type: "broadcastAll",
419
- requestor,
420
- role: "secondary",
421
- order: relativeResponseOrder,
422
- },
423
- });
584
+ let possibleQuorumRespondents = 0;
585
+ for (const { client } of quorumMembers.values()) {
586
+ if (client.details.capabilities.interactive) {
587
+ possibleQuorumRespondents++;
588
+ }
424
589
  }
425
- }, waitTime);
590
+ relativeResponseOrder = possibleQuorumRespondents + Math.random() * 10;
591
+ }
592
+ // When not named to provide update, wait an additional amount
593
+ // of time for those named or others to respond.
594
+ joinResponseDelayMs +=
595
+ exports.broadcastJoinResponseDelaysMs.backupResponderIncrement *
596
+ (3 * updateProviders.length + relativeResponseOrder);
597
+ }
598
+ // Add the requestor to the list of clients that will receive the broadcast.
599
+ const deadlineTime = Date.now() + joinResponseDelayMs;
600
+ this.broadcastRequests.set(requestor, {
601
+ deadlineTime,
602
+ responseOrder: relativeResponseOrder,
603
+ });
604
+ if (!this.reasonForCompleteSnapshot) {
605
+ // Check if requestor count meets or exceeds count of other audience
606
+ // members indicating that we effectively have a complete snapshot
607
+ // (once the current message being processed is processed).
608
+ const interactiveMembersExcludingSelf = this.getInteractiveMembersExcludingSelf(selfClientId);
609
+ if (this.broadcastRequests.size >= interactiveMembersExcludingSelf.all.size) {
610
+ // Note that no action is taken here specifically.
611
+ // We want action to be queued so that it takes place after
612
+ // current message is completely processed. All of the actions
613
+ // below should be delayed (not immediate).
614
+ this.reasonForCompleteSnapshot = "full requests";
615
+ }
616
+ }
617
+ // Check if capable of full primary response. If requested to provide
618
+ // primary response, but do not yet have complete snapshot, we need to
619
+ // delay a full response, until we think we have complete snapshot. In
620
+ // the meantime we will send partial updates as usual.
621
+ if (this.reasonForCompleteSnapshot && updateProviders.includes(selfClientId)) {
622
+ // Use regular message queue to handle timing of the broadcast.
623
+ // Any more immediate broadcasts will accelerate the response time.
624
+ // As a primary responder, it is expected that broadcast will happen and
625
+ // using the regular queue allows other updates to avoid merge work.
626
+ this.enqueueMessage("sendAll", {
627
+ allowableUpdateLatencyMs: joinResponseDelayMs,
628
+ });
629
+ }
630
+ else {
631
+ // Check if there isn't already a timer scheduled to send a join
632
+ // response with in this request's deadline.
633
+ if (this.broadcastRequestsTimer.hasExpired() ||
634
+ deadlineTime < this.broadcastRequestsTimer.expireTime) {
635
+ // Set or update the timer.
636
+ this.broadcastRequestsTimer.setTimeout(this.sendJoinResponseIfStillNeeded, joinResponseDelayMs);
637
+ }
426
638
  }
427
639
  }
428
640
  }