@fluidframework/test-utils 1.4.0-115997 → 2.0.0-dev-rc.1.0.0.224419

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.
Files changed (98) hide show
  1. package/.eslintrc.js +8 -7
  2. package/.mocharc.js +12 -0
  3. package/CHANGELOG.md +177 -0
  4. package/README.md +44 -14
  5. package/api-extractor-lint.json +4 -0
  6. package/api-extractor.json +2 -2
  7. package/api-report/test-utils.api.md +395 -0
  8. package/dist/DriverWrappers.d.ts +32 -0
  9. package/dist/DriverWrappers.d.ts.map +1 -0
  10. package/dist/DriverWrappers.js +57 -0
  11. package/dist/DriverWrappers.js.map +1 -0
  12. package/dist/TestConfigs.d.ts +10 -0
  13. package/dist/TestConfigs.d.ts.map +1 -0
  14. package/dist/TestConfigs.js +17 -0
  15. package/dist/TestConfigs.js.map +1 -0
  16. package/dist/TestSummaryUtils.d.ts +61 -0
  17. package/dist/TestSummaryUtils.d.ts.map +1 -0
  18. package/dist/TestSummaryUtils.js +135 -0
  19. package/dist/TestSummaryUtils.js.map +1 -0
  20. package/dist/containerUtils.d.ts +46 -0
  21. package/dist/containerUtils.d.ts.map +1 -0
  22. package/dist/containerUtils.js +85 -0
  23. package/dist/containerUtils.js.map +1 -0
  24. package/dist/index.d.ts +14 -9
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +43 -19
  27. package/dist/index.js.map +1 -1
  28. package/dist/interfaces.d.ts +6 -0
  29. package/dist/interfaces.d.ts.map +1 -1
  30. package/dist/interfaces.js.map +1 -1
  31. package/dist/loaderContainerTracker.d.ts +39 -15
  32. package/dist/loaderContainerTracker.d.ts.map +1 -1
  33. package/dist/loaderContainerTracker.js +214 -86
  34. package/dist/loaderContainerTracker.js.map +1 -1
  35. package/dist/localCodeLoader.d.ts +9 -2
  36. package/dist/localCodeLoader.d.ts.map +1 -1
  37. package/dist/localCodeLoader.js +18 -17
  38. package/dist/localCodeLoader.js.map +1 -1
  39. package/dist/localLoader.d.ts +3 -2
  40. package/dist/localLoader.d.ts.map +1 -1
  41. package/dist/localLoader.js +2 -0
  42. package/dist/localLoader.js.map +1 -1
  43. package/dist/packageVersion.d.ts +1 -1
  44. package/dist/packageVersion.d.ts.map +1 -1
  45. package/dist/packageVersion.js +1 -1
  46. package/dist/packageVersion.js.map +1 -1
  47. package/dist/retry.d.ts +1 -0
  48. package/dist/retry.d.ts.map +1 -1
  49. package/dist/retry.js +3 -2
  50. package/dist/retry.js.map +1 -1
  51. package/dist/test-utils-alpha.d.ts +305 -0
  52. package/dist/test-utils-beta.d.ts +202 -0
  53. package/dist/test-utils-public.d.ts +202 -0
  54. package/dist/test-utils-untrimmed.d.ts +1001 -0
  55. package/dist/testContainerRuntimeFactory.d.ts +4 -2
  56. package/dist/testContainerRuntimeFactory.d.ts.map +1 -1
  57. package/dist/testContainerRuntimeFactory.js +67 -12
  58. package/dist/testContainerRuntimeFactory.js.map +1 -1
  59. package/dist/testContainerRuntimeFactoryWithDefaultDataStore.d.ts +23 -0
  60. package/dist/testContainerRuntimeFactoryWithDefaultDataStore.d.ts.map +1 -0
  61. package/dist/testContainerRuntimeFactoryWithDefaultDataStore.js +32 -0
  62. package/dist/testContainerRuntimeFactoryWithDefaultDataStore.js.map +1 -0
  63. package/dist/testFluidObject.d.ts +30 -12
  64. package/dist/testFluidObject.d.ts.map +1 -1
  65. package/dist/testFluidObject.js +74 -43
  66. package/dist/testFluidObject.js.map +1 -1
  67. package/dist/testObjectProvider.d.ts +298 -41
  68. package/dist/testObjectProvider.d.ts.map +1 -1
  69. package/dist/testObjectProvider.js +377 -61
  70. package/dist/testObjectProvider.js.map +1 -1
  71. package/dist/timeoutUtils.d.ts +42 -0
  72. package/dist/timeoutUtils.d.ts.map +1 -1
  73. package/dist/timeoutUtils.js +142 -9
  74. package/dist/timeoutUtils.js.map +1 -1
  75. package/dist/tsdoc-metadata.json +11 -0
  76. package/lib/test-utils-alpha.d.ts +305 -0
  77. package/lib/test-utils-beta.d.ts +202 -0
  78. package/lib/test-utils-public.d.ts +202 -0
  79. package/lib/test-utils-untrimmed.d.ts +1001 -0
  80. package/package.json +106 -72
  81. package/prettier.config.cjs +8 -0
  82. package/src/DriverWrappers.ts +83 -0
  83. package/src/TestConfigs.ts +17 -0
  84. package/src/TestSummaryUtils.ts +210 -0
  85. package/src/containerUtils.ts +90 -0
  86. package/src/index.ts +47 -9
  87. package/src/interfaces.ts +16 -7
  88. package/src/loaderContainerTracker.ts +754 -534
  89. package/src/localCodeLoader.ts +87 -86
  90. package/src/localLoader.ts +27 -26
  91. package/src/packageVersion.ts +1 -1
  92. package/src/retry.ts +33 -26
  93. package/src/testContainerRuntimeFactory.ts +152 -59
  94. package/src/testContainerRuntimeFactoryWithDefaultDataStore.ts +61 -0
  95. package/src/testFluidObject.ts +190 -147
  96. package/src/testObjectProvider.ts +931 -372
  97. package/src/timeoutUtils.ts +226 -37
  98. package/tsconfig.json +9 -12
@@ -2,552 +2,772 @@
2
2
  * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
3
  * Licensed under the MIT License.
4
4
  */
5
-
6
- import { assert } from "@fluidframework/common-utils";
5
+ import { assert } from "@fluidframework/core-utils";
7
6
  import { IContainer, IDeltaQueue, IHostLoader } from "@fluidframework/container-definitions";
8
- import { Container } from "@fluidframework/container-loader";
9
- import { IDocumentMessage, ISequencedDocumentMessage, MessageType } from "@fluidframework/protocol-definitions";
7
+ import { ConnectionState } from "@fluidframework/container-loader";
8
+ import { canBeCoalescedByService } from "@fluidframework/driver-utils";
9
+ import {
10
+ IDocumentMessage,
11
+ ISequencedDocumentMessage,
12
+ MessageType,
13
+ } from "@fluidframework/protocol-definitions";
14
+ import {
15
+ IContainerCreateProps,
16
+ IContainerLoadProps,
17
+ // eslint-disable-next-line import/no-internal-modules
18
+ } from "@fluidframework/container-loader/dist/container";
19
+ import { waitForContainerConnection } from "./containerUtils";
10
20
  import { debug } from "./debug";
11
21
  import { IOpProcessingController } from "./testObjectProvider";
22
+ import { timeoutAwait, timeoutPromise } from "./timeoutUtils";
12
23
 
13
24
  const debugOp = debug.extend("ops");
14
25
  const debugWait = debug.extend("wait");
15
26
 
16
27
  interface ContainerRecord {
17
- // A short number for debug output
18
- index: number;
28
+ // A short number for debug output
29
+ index: number;
19
30
 
20
- // LoaderContainerTracker paused state
21
- paused: boolean;
31
+ // LoaderContainerTracker paused state
32
+ paused: boolean;
33
+ pauseP?: Promise<void>; // promise for for the pause that is in progress
22
34
 
23
- // Tracking trailing no-op that may or may be acked by the server so we can discount them
24
- // See issue #5629
25
- startTrailingNoOps: number;
26
- trailingNoOps: number;
35
+ // Tracking trailing no-op that may or may be acked by the server so we can discount them
36
+ // See issue #5629
37
+ startTrailingNoOps: number;
38
+ trailingNoOps: number;
27
39
 
28
- // Track last proposal to ensure no unresolved proposal
29
- lastProposal: number;
40
+ // Track last proposal to ensure no unresolved proposal
41
+ lastProposal: number;
30
42
  }
31
43
 
44
+ /**
45
+ * @alpha
46
+ */
32
47
  export class LoaderContainerTracker implements IOpProcessingController {
33
- private readonly containers = new Map<IContainer, ContainerRecord>();
34
- private lastProposalSeqNum: number = 0;
35
-
36
- constructor(private readonly syncSummarizerClients: boolean = false) {}
37
-
38
- /**
39
- * Add a loader to start to track any container created from them
40
- * @param loader - loader to start tracking any container created.
41
- */
42
- public add<LoaderType extends IHostLoader>(loader: LoaderType) {
43
- // TODO: Expose Loader API to able to intercept container creation (See issue #5114)
44
- const patch = <T, C extends IContainer>(fn: (...args) => Promise<C>) => {
45
- const boundFn = fn.bind(loader);
46
- return async (...args: T[]) => {
47
- const container = await boundFn(...args);
48
- this.addContainer(container);
49
- return container;
50
- };
51
- };
52
- /* eslint-disable @typescript-eslint/unbound-method */
53
- loader.resolve = patch(loader.resolve);
54
- loader.createDetachedContainer = patch(loader.createDetachedContainer);
55
- loader.rehydrateDetachedContainerFromSnapshot = patch(loader.rehydrateDetachedContainerFromSnapshot);
56
- /* eslint-enable @typescript-eslint/unbound-method */
57
- }
58
-
59
- /**
60
- * Utility function to add container to be tracked.
61
- *
62
- * @param container - container to add
63
- */
64
- private addContainer(container: IContainer) {
65
- // ignore summarizer
66
- if (!container.deltaManager.clientDetails.capabilities.interactive && !this.syncSummarizerClients) { return; }
67
-
68
- // don't add container that is already tracked
69
- if (this.containers.has(container)) { return; }
70
-
71
- const record = {
72
- index: this.containers.size,
73
- paused: false,
74
- startTrailingNoOps: 0,
75
- trailingNoOps: 0,
76
- lastProposal: 0,
77
- };
78
- this.containers.set(container, record);
79
- this.trackTrailingNoOps(container, record);
80
- this.trackLastProposal(container);
81
- this.setupTrace(container, record.index);
82
- }
83
-
84
- /**
85
- * Keep track of the trailing NoOp that was sent so we can discount them in the clientSequenceNumber tracking.
86
- * The server might coalesce them with other ops, or a single NoOp, or delay it if it don't think it is necessary.
87
- *
88
- * @param container - the container to track
89
- * @param record - the record to update the trailing op information
90
- */
91
- private trackTrailingNoOps(container: IContainer, record: ContainerRecord) {
92
- container.deltaManager.outbound.on("op", (messages) => {
93
- for (const msg of messages) {
94
- if (msg.type === MessageType.NoOp) {
95
- // Track the NoOp that was sent.
96
- if (record.trailingNoOps === 0) {
97
- // record the starting sequence number of the trailing no ops if we haven't been tracking yet.
98
- record.startTrailingNoOps = msg.clientSequenceNumber;
99
- }
100
- record.trailingNoOps++;
101
- } else {
102
- // Other ops has been sent. We would like to see those ack'ed, so no more need to track NoOps
103
- record.trailingNoOps = 0;
104
- }
105
- }
106
- });
107
-
108
- container.deltaManager.inbound.on("push", (message) => {
109
- // Received the no op back, update the record if we are tracking
110
- if (message.type === MessageType.NoOp
111
- && message.clientId === (container as Container).clientId
112
- && record.trailingNoOps !== 0
113
- && record.startTrailingNoOps <= message.clientSequenceNumber
114
- ) {
115
- // NoOp might have coalesced and skipped ahead some sequence number
116
- // update the record and skip ahead as well
117
- const oldStartTrailingNoOps = record.startTrailingNoOps;
118
- record.startTrailingNoOps = message.clientSequenceNumber + 1;
119
- record.trailingNoOps -= (record.startTrailingNoOps - oldStartTrailingNoOps);
120
- }
121
- });
122
-
123
- container.on("disconnected", () => {
124
- // reset on disconnect.
125
- record.trailingNoOps = 0;
126
- });
127
- }
128
-
129
- private trackLastProposal(container: IContainer) {
130
- container.on("codeDetailsProposed", (value, proposal) => {
131
- if (proposal.sequenceNumber > this.lastProposalSeqNum) {
132
- this.lastProposalSeqNum = proposal.sequenceNumber;
133
- }
134
- });
135
- }
136
-
137
- /**
138
- * Reset the tracker, closing all containers and stop tracking them.
139
- */
140
- public reset() {
141
- this.lastProposalSeqNum = 0;
142
- for (const container of this.containers.keys()) {
143
- container.close();
144
- }
145
- this.containers.clear();
146
-
147
- // REVIEW: do we need to unpatch the loaders?
148
- }
149
-
150
- /**
151
- * Make sure all the tracked containers are synchronized.
152
- * - No isDirty (non-readonly) containers
153
- * - No extra clientId in quorum of any container that is not tracked and still opened.
154
- * - i.e. no pending Join/Leave message.
155
- * - No unresolved proposal (minSeqNum \>= lastProposalSeqNum)
156
- * - lastSequenceNumber of all container is the same
157
- * - clientSequenceNumberObserved is the same as clientSequenceNumber sent
158
- * - this overlaps with !isDirty, but include task scheduler ops.
159
- * - Trailing NoOp is tracked and don't count as pending ops.
160
- */
161
- public async ensureSynchronized(...containers: IContainer[]) {
162
- const resumed = this.resumeProcessing(...containers);
163
-
164
- let waitingSequenceNumberSynchronized = false;
165
- // eslint-disable-next-line no-constant-condition
166
- while (true) {
167
- const containersToApply = this.getContainers(containers);
168
- if (containersToApply.length === 0) { break; }
169
-
170
- // Ignore readonly dirty containers, because it can't sent up and nothing can be done about it being dirty
171
- const dirtyContainers = containersToApply.filter((c) => {
172
- const { deltaManager, isDirty } = c;
173
- return deltaManager.readOnlyInfo.readonly !== true && isDirty;
174
- });
175
- if (dirtyContainers.length === 0) {
176
- // Wait for all the leave messages
177
- const pendingClients = this.getPendingClients(containersToApply);
178
- if (pendingClients.length === 0) {
179
- if (this.isSequenceNumberSynchronized(containersToApply)) {
180
- // done, we are in sync
181
- break;
182
- }
183
- if (!waitingSequenceNumberSynchronized) {
184
- // Only write it out once
185
- waitingSequenceNumberSynchronized = true;
186
- debugWait("Waiting for sequence number synchronized");
187
- await this.waitForAnyInboundOps(containersToApply);
188
- }
189
- } else {
190
- waitingSequenceNumberSynchronized = false;
191
- await this.waitForPendingClients(pendingClients);
192
- }
193
- } else {
194
- // Wait for all the containers to be saved
195
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
196
- debugWait(`Waiting container to be saved ${dirtyContainers.map((c) => this.containers.get(c)!.index)}`);
197
- waitingSequenceNumberSynchronized = false;
198
- await Promise.all(dirtyContainers.map(async (c) => Promise.race(
199
- [new Promise((resolve) => c.once("saved", resolve)),
200
- new Promise((resolve) => c.once("closed", resolve))],
201
- )));
202
- }
203
-
204
- // yield a turn to allow side effect of the ops we just processed execute before we check again
205
- await new Promise<void>((resolve) => { setTimeout(resolve, 0); });
206
- }
207
-
208
- // Pause all container that was resumed
209
- // don't call pause if resumed is empty and pause everything, which is not what we want
210
- if (resumed.length !== 0) {
211
- await this.pauseProcessing(...resumed);
212
- }
213
-
214
- debugWait("Synchronized");
215
- }
216
-
217
- /**
218
- * Utility to calculate the set of clientId per container in quorum that is NOT associated with
219
- * any container we tracked, indicating there is a pending join or leave op that we need to wait.
220
- *
221
- * @param containersToApply - the set of containers to check
222
- */
223
- private getPendingClients(containersToApply: IContainer[]) {
224
- // All the clientId we track should be a superset of the quorum, otherwise, we are missing
225
- // leave messages
226
- const openedDocuments = Array.from(this.containers.keys()).filter((c) => !c.closed);
227
- const openedClientId = openedDocuments.map((container) => (container as Container).clientId);
228
-
229
- const pendingClients: [IContainer, Set<string>][] = [];
230
- containersToApply.forEach((container) => {
231
- const pendingClientId = new Set<string>();
232
- const quorum = container.getQuorum();
233
- quorum.getMembers().forEach((client, clientId) => {
234
- // ignore summarizer
235
- if (!client.client.details.capabilities.interactive && !this.syncSummarizerClients) { return; }
236
- if (!openedClientId.includes(clientId)) {
237
- pendingClientId.add(clientId);
238
- }
239
- });
240
-
241
- if (pendingClientId.size !== 0) {
242
- pendingClients.push([container, pendingClientId]);
243
- }
244
- });
245
- return pendingClients;
246
- }
247
-
248
- /**
249
- * Utility to check synchronization based on sequence number
250
- * See ensureSynchronized for more detail
251
- *
252
- * @param containersToApply - the set of containers to check
253
- */
254
- private isSequenceNumberSynchronized(containersToApply: IContainer[]) {
255
- // clientSequenceNumber check detects ops in flight, both on the wire and in the outbound queue
256
- // We need both client sequence number and isDirty check because:
257
- // - Currently isDirty flag ignores ops for task scheduler, so we need the client sequence number check
258
- // - But isDirty flags include ops during forceReadonly and disconnected, because we don't submit
259
- // the ops in the first place, clientSequenceNumber is not assigned
260
-
261
- const isClientSequenceNumberSynchronized = containersToApply.every((container) => {
262
- if (container.deltaManager.readOnlyInfo.readonly === true) {
263
- // Ignore readonly container. the clientSeqNum and clientSeqNumObserved might be out of sync
264
- // because we transition to readonly when outbound is not empty or the in transit op got lost
265
- return true;
266
- }
267
- // Note that in read only mode, the op won't be submitted
268
- let deltaManager = (container.deltaManager as any);
269
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
270
- const { trailingNoOps } = this.containers.get(container)!;
271
- // Back-compat: clientSequenceNumber & clientSequenceNumberObserved moved to ConnectionManager in 0.53
272
- if (!("clientSequenceNumber" in deltaManager)) {
273
- deltaManager = deltaManager.connectionManager;
274
- }
275
- assert("clientSequenceNumber" in deltaManager, "no clientSequenceNumber");
276
- assert("clientSequenceNumberObserved" in deltaManager, "no clientSequenceNumber");
277
- return deltaManager.clientSequenceNumber ===
278
- (deltaManager.clientSequenceNumberObserved as number) + trailingNoOps;
279
- });
280
-
281
- if (!isClientSequenceNumberSynchronized) {
282
- return false;
283
- }
284
-
285
- const minSeqNum = containersToApply[0].deltaManager.minimumSequenceNumber;
286
- if (minSeqNum < this.lastProposalSeqNum) {
287
- // There is an unresolved proposal
288
- return false;
289
- }
290
-
291
- // Check to see if all the container has process the same number of ops.
292
- const seqNum = containersToApply[0].deltaManager.lastSequenceNumber;
293
- return containersToApply.every((c) => c.deltaManager.lastSequenceNumber === seqNum);
294
- }
295
-
296
- /**
297
- * Utility to wait for any clientId in quorum that is NOT associated with any container we
298
- * tracked, indicating there is a pending join or leave op that we need to wait.
299
- *
300
- * Note that this function doesn't account for container that got added after we started waiting
301
- *
302
- * @param containersToApply - the set of containers to wait for any inbound ops for
303
- */
304
- private async waitForPendingClients(pendingClients: [IContainer, Set<string>][]) {
305
- const unconnectedClients =
306
- Array.from(this.containers.keys()).filter((c) => !c.closed && !(c as Container).connected);
307
- return Promise.all(pendingClients.map(async ([container, pendingClientId]) => {
308
- return new Promise<void>((resolve) => {
309
- const cleanup = () => {
310
- unconnectedClients.forEach((c) => c.off("connected", handler));
311
- container.getQuorum().off("removeMember", handler);
312
- };
313
- const handler = (clientId: string) => {
314
- pendingClientId.delete(clientId);
315
- if (pendingClientId.size === 0) {
316
- cleanup();
317
- resolve();
318
- }
319
- };
320
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
321
- const index = this.containers.get(container)!.index;
322
- debugWait(`${index}: Waiting for pending clients ${Array.from(pendingClientId.keys())}`);
323
- unconnectedClients.forEach((c) => c.on("connected", handler));
324
- container.getQuorum().on("removeMember", handler);
325
- container.on("closed", () => {
326
- cleanup();
327
- resolve();
328
- });
329
- });
330
- }));
331
- }
332
-
333
- /**
334
- * Utility to wait for any inbound ops from a set of containers
335
- * @param containersToApply - the set of containers to wait for any inbound ops for
336
- */
337
- private async waitForAnyInboundOps(containersToApply: IContainer[]) {
338
- return new Promise<void>((resolve) => {
339
- const handler = () => {
340
- containersToApply.map((c) => {
341
- c.deltaManager.inbound.off("push", handler);
342
- });
343
- resolve();
344
- };
345
- containersToApply.map((c) => {
346
- c.deltaManager.inbound.on("push", handler);
347
- });
348
- });
349
- }
350
-
351
- /**
352
- * Resume all queue activities on all paused tracked containers and return them
353
- */
354
- public resumeProcessing(...containers: IContainer[]) {
355
- const resumed: IContainer[] = [];
356
- const containersToApply = this.getContainers(containers);
357
- for (const container of containersToApply) {
358
- const record = this.containers.get(container);
359
- if (record?.paused === true) {
360
- debugWait(`${record.index}: container resumed`);
361
- container.deltaManager.inbound.resume();
362
- container.deltaManager.outbound.resume();
363
- resumed.push(container);
364
- record.paused = false;
365
- }
366
- }
367
- return resumed;
368
- }
369
-
370
- /**
371
- * Pause all queue activities on the containers given, or all tracked containers
372
- * Any containers given that is not tracked will be ignored.
373
- */
374
- public async pauseProcessing(...containers: IContainer[]) {
375
- const pauseP: Promise<void>[] = [];
376
- const containersToApply = this.getContainers(containers);
377
- for (const container of containersToApply) {
378
- const record = this.containers.get(container);
379
- if (record !== undefined && !record.paused) {
380
- debugWait(`${record.index}: container paused`);
381
- pauseP.push(container.deltaManager.inbound.pause());
382
- pauseP.push(container.deltaManager.outbound.pause());
383
- record.paused = true;
384
- }
385
- }
386
- await Promise.all(pauseP);
387
- }
388
-
389
- /**
390
- * Pause all queue activities on all tracked containers, and resume only
391
- * inbound to process ops until it is idle. All queues are left in the paused state
392
- * after the function
393
- */
394
- public async processIncoming(...containers: IContainer[]) {
395
- return this.processQueue(containers, (container) => container.deltaManager.inbound);
396
- }
397
-
398
- /**
399
- * Pause all queue activities on all tracked containers, and resume only
400
- * outbound to process ops until it is idle. All queues are left in the paused state
401
- * after the function
402
- */
403
- public async processOutgoing(...containers: IContainer[]) {
404
- return this.processQueue(containers, (container) => container.deltaManager.outbound);
405
- }
406
-
407
- /**
408
- * Implementation of processIncoming and processOutgoing
409
- */
410
- private async processQueue<U>(containers: IContainer[], getQueue: (container: IContainer) => IDeltaQueue<U>) {
411
- await this.pauseProcessing(...containers);
412
- const resumed: IDeltaQueue<U>[] = [];
413
-
414
- const containersToApply = this.getContainers(containers);
415
- const inflightTracker = new Map<IContainer, number>();
416
- const cleanup: (() => void)[] = [];
417
- for (const container of containersToApply) {
418
- const queue = getQueue(container);
419
-
420
- // track the outgoing ops (if any) to make sure they make the round trip to at least to the same client
421
- // to make sure they are sequenced.
422
- cleanup.push(this.setupInOutTracker(container, inflightTracker));
423
- queue.resume();
424
- resumed.push(queue);
425
- }
426
-
427
- while (resumed.some((queue) => !queue.idle)) {
428
- debugWait("Wait until queue is idle");
429
- await new Promise<void>((resolve) => { setTimeout(resolve, 0); });
430
- }
431
-
432
- // Make sure all the op that we sent out are acked first
433
- // This is no op if we are processing incoming
434
- if (inflightTracker.size) {
435
- debugWait("Wait for inflight ops");
436
- do {
437
- await this.waitForAnyInboundOps(containersToApply);
438
- } while (inflightTracker.size);
439
- }
440
-
441
- // remove the handlers
442
- cleanup.forEach((clean) => clean());
443
-
444
- await Promise.all(resumed.map(async (queue) => queue.pause()));
445
- }
446
-
447
- /**
448
- * Utility to set up listener to track the outbound ops until it round trip back
449
- * Returns a function to remove the handler after it is done.
450
- *
451
- * @param container - the container to setup
452
- * @param inflightTracker - a map to track the clientSequenceNumber per container it expect to get ops back
453
- */
454
- private setupInOutTracker(container: IContainer, inflightTracker: Map<IContainer, number>) {
455
- const outHandler = (messages: IDocumentMessage[]) => {
456
- for (const message of messages) {
457
- if (message.type !== MessageType.NoOp) {
458
- inflightTracker.set(container, message.clientSequenceNumber);
459
- }
460
- }
461
- };
462
- const inHandler = (message: ISequencedDocumentMessage) => {
463
- if (message.type !== MessageType.NoOp
464
- && message.clientId === (container as Container).clientId
465
- && inflightTracker.get(container) === message.clientSequenceNumber) {
466
- inflightTracker.delete(container);
467
- }
468
- };
469
-
470
- container.deltaManager.outbound.on("op", outHandler);
471
- container.deltaManager.inbound.on("push", inHandler);
472
-
473
- return () => {
474
- container.deltaManager.outbound.off("op", outHandler);
475
- container.deltaManager.inbound.off("push", inHandler);
476
- };
477
- }
478
-
479
- /**
480
- * Setup debug traces for connection and ops
481
- */
482
- private setupTrace(container: IContainer, index: number) {
483
- if (debugOp.enabled) {
484
- const getContentsString = (type: string, msgContents: any) => {
485
- try {
486
- if (type !== MessageType.Operation) {
487
- if (typeof msgContents === "string") { return msgContents; }
488
- return JSON.stringify(msgContents);
489
- }
490
- let address = "";
491
-
492
- // contents comes in the wire as JSON string ("push" event)
493
- // But already parsed when apply ("op" event)
494
- let contents = typeof msgContents === "string" ?
495
- JSON.parse(msgContents) : msgContents;
496
- while (contents !== undefined && contents !== null) {
497
- if (contents.contents?.address !== undefined) {
498
- address += `/${contents.contents.address}`;
499
- contents = contents.contents.contents;
500
- } else if (contents.content?.address !== undefined) {
501
- address += `/${contents.content.address}`;
502
- contents = contents.content.contents;
503
- } else {
504
- break;
505
- }
506
- }
507
- if (address) {
508
- return `${address} ${JSON.stringify(contents)}`;
509
- }
510
- return JSON.stringify(contents);
511
- } catch (e: any) {
512
- return `${e.message}: ${e.stack}`;
513
- }
514
- };
515
- debugOp(`${index}: ADD: clientId: ${(container as Container).clientId}`);
516
- container.deltaManager.outbound.on("op", (messages) => {
517
- for (const msg of messages) {
518
- debugOp(`${index}: OUT: `
519
- + `cli: ${msg.clientSequenceNumber.toString().padStart(3)} `
520
- + `rsq: ${msg.referenceSequenceNumber.toString().padStart(3)} `
521
- + `${msg.type} ${getContentsString(msg.type, msg.contents)}`);
522
- }
523
- });
524
- const getInboundHandler = (type: string) => {
525
- return (msg: ISequencedDocumentMessage) => {
526
- const clientSeq = msg.clientId === (container as Container).clientId ?
527
- `cli: ${msg.clientSequenceNumber.toString().padStart(3)}` : " ";
528
- debugOp(`${index}: ${type}: seq: ${msg.sequenceNumber.toString().padStart(3)} `
529
- + `${clientSeq} min: ${msg.minimumSequenceNumber.toString().padStart(3)} `
530
- + `${msg.type} ${getContentsString(msg.type, msg.contents)}`);
531
- };
532
- };
533
- container.deltaManager.inbound.on("push", getInboundHandler("IN "));
534
- container.deltaManager.inbound.on("op", getInboundHandler("OP "));
535
- container.deltaManager.on("connect", (details) => {
536
- debugOp(`${index}: CON: clientId: ${details.clientId}`);
537
- });
538
- container.deltaManager.on("disconnect", (reason) => {
539
- debugOp(`${index}: DIS: ${reason}`);
540
- });
541
- }
542
- }
543
-
544
- /**
545
- * Filter out the opened containers based on param.
546
- * @param containers - The container to filter to. If the array is empty, it means don't filter and return
547
- * all open containers.
548
- */
549
- private getContainers(containers: IContainer[]) {
550
- const containersToApply = containers.length === 0 ? Array.from(this.containers.keys()) : containers;
551
- return containersToApply.filter((container) => !container.closed);
552
- }
48
+ private readonly containers = new Map<IContainer, ContainerRecord>();
49
+ private lastProposalSeqNum: number = 0;
50
+
51
+ constructor(private readonly syncSummarizerClients: boolean = false) {}
52
+
53
+ /**
54
+ * Add a loader to start to track any container created from them
55
+ * @param loader - loader to start tracking any container created.
56
+ */
57
+ public add<LoaderType extends IHostLoader>(loader: LoaderType) {
58
+ // TODO: Expose Loader API to able to intercept container creation (See issue #5114)
59
+ const patch = <T, C extends IContainer>(fn: (...args) => Promise<C>) => {
60
+ const boundFn = fn.bind(loader);
61
+ return async (...args: T[]) => {
62
+ const container = await boundFn(...args);
63
+ this.addContainer(container);
64
+ return container;
65
+ };
66
+ };
67
+ /* eslint-disable @typescript-eslint/unbound-method */
68
+ loader.resolve = patch(loader.resolve);
69
+ loader.createDetachedContainer = patch(loader.createDetachedContainer);
70
+ loader.rehydrateDetachedContainerFromSnapshot = patch(
71
+ loader.rehydrateDetachedContainerFromSnapshot,
72
+ );
73
+ /* eslint-enable @typescript-eslint/unbound-method */
74
+ }
75
+
76
+ /**
77
+ * Utility function to add container to be tracked.
78
+ *
79
+ * @param container - container to add
80
+ */
81
+ private addContainer(container: IContainer) {
82
+ // don't add container that is already tracked
83
+ if (this.containers.has(container)) {
84
+ return;
85
+ }
86
+
87
+ // Container has a `clone` method that can be used to create another container without going through
88
+ // the Loader. Such containers won't be added by the `add` method so do it here. For example, summarizer
89
+ // containers are created via the `clone` method.
90
+ // Created a type with clone (which is not on IContainer and is readonly) rather than typing to any.
91
+ type ContainerWithClone = IContainer & {
92
+ clone: (
93
+ loadProps: IContainerLoadProps,
94
+ createParamOverrides: Partial<IContainerCreateProps>,
95
+ ) => Promise<IContainer>;
96
+ };
97
+ const containerWithClone = container as ContainerWithClone;
98
+
99
+ // back-compat: Check for undefined because this function was added recently and older containers won't have it.
100
+ if (containerWithClone.clone !== undefined) {
101
+ const patch = <T, C extends IContainer>(fn: (...args) => Promise<C>) => {
102
+ const boundFn = fn.bind(containerWithClone);
103
+ return async (...args: T[]) => {
104
+ const newContainer = await boundFn(...args);
105
+ this.addContainer(newContainer);
106
+ return newContainer;
107
+ };
108
+ };
109
+ containerWithClone.clone = patch(containerWithClone.clone);
110
+ }
111
+
112
+ // ignore summarizer
113
+ if (
114
+ !container.deltaManager.clientDetails.capabilities.interactive &&
115
+ !this.syncSummarizerClients
116
+ ) {
117
+ return;
118
+ }
119
+
120
+ const record = {
121
+ index: this.containers.size,
122
+ paused: false,
123
+ startTrailingNoOps: 0,
124
+ trailingNoOps: 0,
125
+ lastProposal: 0,
126
+ };
127
+ this.containers.set(container, record);
128
+ this.trackTrailingNoOps(container, record);
129
+ this.trackLastProposal(container);
130
+ this.setupTrace(container, record.index);
131
+ }
132
+
133
+ /**
134
+ * Keep track of the trailing NoOp that was sent so we can discount them in the clientSequenceNumber tracking.
135
+ * The server might coalesce them with other ops, or a single NoOp, or delay it if it don't think it is necessary.
136
+ *
137
+ * @param container - the container to track
138
+ * @param record - the record to update the trailing op information
139
+ */
140
+ private trackTrailingNoOps(container: IContainer, record: ContainerRecord) {
141
+ container.deltaManager.outbound.on("op", (messages) => {
142
+ for (const msg of messages) {
143
+ if (canBeCoalescedByService(msg)) {
144
+ // Track the NoOp that was sent.
145
+ if (record.trailingNoOps === 0) {
146
+ // record the starting sequence number of the trailing no ops if we haven't been tracking yet.
147
+ record.startTrailingNoOps = msg.clientSequenceNumber;
148
+ }
149
+ record.trailingNoOps++;
150
+ } else {
151
+ // Other ops has been sent. We would like to see those ack'ed, so no more need to track NoOps
152
+ record.trailingNoOps = 0;
153
+ }
154
+ }
155
+ });
156
+
157
+ container.deltaManager.inbound.on("push", (message) => {
158
+ // Received the no op back, update the record if we are tracking
159
+ if (
160
+ canBeCoalescedByService(message) &&
161
+ message.clientId === container.clientId &&
162
+ record.trailingNoOps !== 0 &&
163
+ record.startTrailingNoOps <= message.clientSequenceNumber
164
+ ) {
165
+ // NoOp might have coalesced and skipped ahead some sequence number
166
+ // update the record and skip ahead as well
167
+ const oldStartTrailingNoOps = record.startTrailingNoOps;
168
+ record.startTrailingNoOps = message.clientSequenceNumber + 1;
169
+ record.trailingNoOps -= record.startTrailingNoOps - oldStartTrailingNoOps;
170
+ }
171
+ });
172
+
173
+ container.on("disconnected", () => {
174
+ // reset on disconnect.
175
+ record.trailingNoOps = 0;
176
+ });
177
+ }
178
+
179
+ private trackLastProposal(container: IContainer) {
180
+ container.on("codeDetailsProposed", (value, proposal) => {
181
+ if (proposal.sequenceNumber > this.lastProposalSeqNum) {
182
+ this.lastProposalSeqNum = proposal.sequenceNumber;
183
+ }
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Reset the tracker, closing all containers and stop tracking them.
189
+ */
190
+ public reset() {
191
+ this.lastProposalSeqNum = 0;
192
+ for (const container of this.containers.keys()) {
193
+ container.close();
194
+ }
195
+ this.containers.clear();
196
+
197
+ // REVIEW: do we need to unpatch the loaders?
198
+ }
199
+
200
+ /**
201
+ * Make sure all the tracked containers are synchronized.
202
+ *
203
+ * No isDirty (non-readonly) containers
204
+ * No extra clientId in quorum of any container that is not tracked and still opened.
205
+ * - i.e. no pending Join/Leave message.
206
+ * No unresolved proposal (minSeqNum \>= lastProposalSeqNum)
207
+ * lastSequenceNumber of all container is the same
208
+ * clientSequenceNumberObserved is the same as clientSequenceNumber sent
209
+ * - this overlaps with !isDirty, but include task scheduler ops.
210
+ * - Trailing NoOp is tracked and don't count as pending ops.
211
+ *
212
+ * Containers that are already pause will resume process and paused again once
213
+ * everything is synchronized. Containers that aren't paused will remain unpaused when this
214
+ * function returns.
215
+ */
216
+ public async ensureSynchronized(...containers: IContainer[]): Promise<void> {
217
+ const resumed = this.resumeProcessing(...containers);
218
+
219
+ let waitingSequenceNumberSynchronized: string | undefined;
220
+ // eslint-disable-next-line no-constant-condition
221
+ while (true) {
222
+ // yield a turn to allow side effect of resuming or the ops we just processed execute before we check
223
+ await new Promise<void>((resolve) => {
224
+ setTimeout(resolve, 0);
225
+ });
226
+
227
+ const containersToApply = this.getContainers(containers);
228
+ if (containersToApply.length === 0) {
229
+ break;
230
+ }
231
+
232
+ // Ignore readonly dirty containers, because it can't sent ops and nothing can be done about it being dirty
233
+ const dirtyContainers = containersToApply.filter((c) => {
234
+ const { deltaManager, isDirty } = c;
235
+ return deltaManager.readOnlyInfo.readonly !== true && isDirty;
236
+ });
237
+ if (dirtyContainers.length === 0) {
238
+ // Wait for all the leave messages
239
+ const pendingClients = this.getPendingClients(containersToApply);
240
+ if (pendingClients.length === 0) {
241
+ const needSync = this.needSequenceNumberSynchronize(containersToApply);
242
+ if (needSync === undefined) {
243
+ // done, we are in sync
244
+ break;
245
+ }
246
+ if (waitingSequenceNumberSynchronized !== needSync.reason) {
247
+ // Don't repeat writing to console if it is the same reason
248
+ waitingSequenceNumberSynchronized = needSync.reason;
249
+ debugWait(needSync.message);
250
+ }
251
+ // Wait for one inbounds ops which might change the state of things
252
+ await timeoutAwait(this.waitForAnyInboundOps(containersToApply), {
253
+ errorMsg: `Timeout on ${needSync.message}`,
254
+ });
255
+ } else {
256
+ waitingSequenceNumberSynchronized = undefined;
257
+ await timeoutAwait(this.waitForPendingClients(pendingClients), {
258
+ errorMsg: "Timeout on waiting for pending join or leave op",
259
+ });
260
+ }
261
+ } else {
262
+ // Wait for all the containers to be saved
263
+ debugWait(
264
+ `Waiting container to be saved ${this.containerIndexStrings(dirtyContainers)}`,
265
+ );
266
+ waitingSequenceNumberSynchronized = undefined;
267
+ await Promise.all(
268
+ dirtyContainers.map(async (c) =>
269
+ Promise.race([
270
+ timeoutPromise((resolve) => c.once("saved", () => resolve()), {
271
+ errorMsg: "Timeout on waiting a container to be saved",
272
+ }),
273
+ new Promise((resolve) => c.once("closed", resolve)),
274
+ ]),
275
+ ),
276
+ );
277
+ }
278
+ }
279
+
280
+ // Pause all container that was resumed
281
+ // don't call pause if resumed is empty and pause everything, which is not what we want
282
+ if (resumed.length !== 0) {
283
+ await timeoutAwait(this.pauseProcessing(...resumed), {
284
+ errorMsg: "Timeout on waiting for pausing all resumed containers",
285
+ });
286
+ }
287
+
288
+ debugWait("Synchronized");
289
+ }
290
+
291
+ /**
292
+ * Utility to calculate the set of clientId per container in quorum that is NOT associated with
293
+ * any container we tracked, indicating there is a pending join or leave op that we need to wait.
294
+ *
295
+ * @param containersToApply - the set of containers to check
296
+ */
297
+ private getPendingClients(containersToApply: IContainer[]) {
298
+ // All the clientId we track should be a superset of the quorum, otherwise, we are missing
299
+ // leave messages
300
+ const openedDocuments = Array.from(this.containers.keys()).filter((c) => !c.closed);
301
+ const openedClientId = openedDocuments.map((container) => container.clientId);
302
+
303
+ const pendingClients: [IContainer, Set<string>][] = [];
304
+ containersToApply.forEach((container) => {
305
+ const pendingClientId = new Set<string>();
306
+ const quorum = container.getQuorum();
307
+ quorum.getMembers().forEach((client, clientId) => {
308
+ // ignore summarizer
309
+ if (
310
+ !client.client.details.capabilities.interactive &&
311
+ !this.syncSummarizerClients
312
+ ) {
313
+ return;
314
+ }
315
+ if (!openedClientId.includes(clientId)) {
316
+ pendingClientId.add(clientId);
317
+ }
318
+ });
319
+
320
+ if (pendingClientId.size !== 0) {
321
+ pendingClients.push([container, pendingClientId]);
322
+ }
323
+ });
324
+ return pendingClients;
325
+ }
326
+
327
+ /**
328
+ * Utility to check synchronization based on sequence number
329
+ * See ensureSynchronized for more detail
330
+ *
331
+ * @param containersToApply - the set of containers to check
332
+ */
333
+ private needSequenceNumberSynchronize(containersToApply: IContainer[]) {
334
+ // If there is a pending proposal, wait for it to be accepted
335
+ const minSeqNum = containersToApply[0].deltaManager.minimumSequenceNumber;
336
+ if (minSeqNum < this.lastProposalSeqNum) {
337
+ return {
338
+ reason: "Proposal",
339
+ message: `waiting for MSN to advance to proposal at sequence number ${this.lastProposalSeqNum}`,
340
+ };
341
+ }
342
+
343
+ // clientSequenceNumber check detects ops in flight, both on the wire and in the outbound queue
344
+ // We need both client sequence number and isDirty check because:
345
+ // - Currently isDirty flag ignores ops for task scheduler, so we need the client sequence number check
346
+ // - But isDirty flags include ops during forceReadonly and disconnected, because we don't submit
347
+ // the ops in the first place, clientSequenceNumber is not assigned
348
+
349
+ const containerWithInflightOps = containersToApply.filter((container) => {
350
+ if (container.deltaManager.readOnlyInfo.readonly === true) {
351
+ // Ignore readonly container. the clientSeqNum and clientSeqNumObserved might be out of sync
352
+ // because we transition to readonly when outbound is not empty or the in transit op got lost
353
+ return false;
354
+ }
355
+ // Note that in read only mode, the op won't be submitted
356
+ let deltaManager = container.deltaManager as any;
357
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
358
+ const { trailingNoOps } = this.containers.get(container)!;
359
+ // Back-compat: lastSubmittedClientId/clientSequenceNumber/clientSequenceNumberObserved moved to ConnectionManager in 0.53
360
+ if (!("clientSequenceNumber" in deltaManager)) {
361
+ deltaManager = deltaManager.connectionManager;
362
+ }
363
+ assert("clientSequenceNumber" in deltaManager, "no clientSequenceNumber");
364
+ assert("clientSequenceNumberObserved" in deltaManager, "no clientSequenceNumber");
365
+ // If last submittedClientId isn't the current clientId, then we haven't send any ops
366
+ return (
367
+ deltaManager.lastSubmittedClientId === container.clientId &&
368
+ deltaManager.clientSequenceNumber !==
369
+ (deltaManager.clientSequenceNumberObserved as number) + trailingNoOps
370
+ );
371
+ });
372
+
373
+ if (containerWithInflightOps.length !== 0) {
374
+ return {
375
+ reason: "InflightOps",
376
+ message: `waiting for containers with inflight ops: ${this.containerIndexStrings(
377
+ containerWithInflightOps,
378
+ )}`,
379
+ };
380
+ }
381
+
382
+ // Check to see if all the container has process the same number of ops.
383
+ const maxSeqNum = Math.max(
384
+ ...containersToApply.map((c) => c.deltaManager.lastSequenceNumber),
385
+ );
386
+ const containerWithPendingIncoming = containersToApply.filter(
387
+ (c) => c.deltaManager.lastSequenceNumber !== maxSeqNum,
388
+ );
389
+ if (containerWithPendingIncoming.length !== 0) {
390
+ return {
391
+ reason: "Pending",
392
+ message: `waiting for containers with pending incoming ops up to sequence number ${maxSeqNum}: ${this.containerIndexStrings(
393
+ containerWithPendingIncoming,
394
+ )}`,
395
+ };
396
+ }
397
+ return undefined;
398
+ }
399
+
400
+ private containerIndexStrings(containers: IContainer[]) {
401
+ return containers.map(
402
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
403
+ (c) => this.containers.get(c)!.index,
404
+ );
405
+ }
406
+
407
+ /**
408
+ * Utility to wait for any clientId in quorum that is NOT associated with any container we
409
+ * tracked, indicating there is a pending join or leave op that we need to wait.
410
+ *
411
+ * Note that this function doesn't account for container that got added after we started waiting
412
+ *
413
+ * @param containersToApply - the set of containers to wait for any inbound ops for
414
+ */
415
+ private async waitForPendingClients(pendingClients: [IContainer, Set<string>][]) {
416
+ const unconnectedClients = Array.from(this.containers.keys()).filter(
417
+ (c) => !c.closed && c.connectionState !== ConnectionState.Connected,
418
+ );
419
+ return Promise.all(
420
+ pendingClients.map(async ([container, pendingClientId]) => {
421
+ return new Promise<void>((resolve) => {
422
+ const cleanup = () => {
423
+ unconnectedClients.forEach((c) => c.off("connected", handler));
424
+ container.getQuorum().off("removeMember", handler);
425
+ };
426
+ const handler = (clientId: string) => {
427
+ pendingClientId.delete(clientId);
428
+ if (pendingClientId.size === 0) {
429
+ cleanup();
430
+ resolve();
431
+ }
432
+ };
433
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
434
+ const index = this.containers.get(container)!.index;
435
+ debugWait(
436
+ `${index}: Waiting for pending clients ${Array.from(
437
+ pendingClientId.keys(),
438
+ )}`,
439
+ );
440
+ unconnectedClients.forEach((c) => c.on("connected", handler));
441
+ container.getQuorum().on("removeMember", handler);
442
+ container.on("closed", () => {
443
+ cleanup();
444
+ resolve();
445
+ });
446
+ });
447
+ }),
448
+ );
449
+ }
450
+
451
+ /**
452
+ * Utility to wait for any inbound ops from a set of containers
453
+ * @param containersToApply - the set of containers to wait for any inbound ops for
454
+ */
455
+ private async waitForAnyInboundOps(containersToApply: IContainer[]) {
456
+ return new Promise<void>((resolve) => {
457
+ const handler = () => {
458
+ containersToApply.map((c) => {
459
+ c.deltaManager.inbound.off("push", handler);
460
+ });
461
+ resolve();
462
+ };
463
+ containersToApply.map((c) => {
464
+ c.deltaManager.inbound.on("push", handler);
465
+ });
466
+ });
467
+ }
468
+
469
+ /**
470
+ * Resume all queue activities on all paused tracked containers and return them
471
+ */
472
+ public resumeProcessing(...containers: IContainer[]) {
473
+ const resumed: IContainer[] = [];
474
+ const containersToApply = this.getContainers(containers);
475
+ for (const container of containersToApply) {
476
+ const record = this.containers.get(container);
477
+ assert(
478
+ record?.pauseP === undefined,
479
+ "Cannot resume container while pausing is in progress",
480
+ );
481
+ if (record?.paused === true) {
482
+ debugWait(`${record.index}: container resumed`);
483
+ container.deltaManager.inbound.resume();
484
+ container.deltaManager.outbound.resume();
485
+ resumed.push(container);
486
+ record.paused = false;
487
+ }
488
+ }
489
+ return resumed;
490
+ }
491
+
492
+ /**
493
+ * Pause all queue activities on the containers given, or all tracked containers
494
+ * Any containers given that is not tracked will be ignored.
495
+ *
496
+ * When a container is paused, it is assumed that we want fine grain control over op
497
+ * sequencing. This function will prepare the container and force it into write mode to
498
+ * avoid missing join messages or change the sequence of event when switching from read to
499
+ * write mode.
500
+ */
501
+ public async pauseProcessing(...containers: IContainer[]) {
502
+ const waitP: Promise<void>[] = [];
503
+ const containersToApply = this.getContainers(containers);
504
+ for (const container of containersToApply) {
505
+ const record = this.containers.get(container);
506
+ if (record !== undefined && !record.paused) {
507
+ if (record.pauseP === undefined) {
508
+ record.pauseP = this.pauseContainer(container, record);
509
+ }
510
+ waitP.push(record.pauseP);
511
+ }
512
+ }
513
+ await Promise.all(waitP);
514
+ }
515
+
516
+ /**
517
+ * When a container is paused, it is assumed that we want fine grain control over op
518
+ * sequencing. This function will prepare the container and force it into write mode to
519
+ * avoid missing join messages or change the sequence of event when switching from read to
520
+ * write mode.
521
+ *
522
+ * @param container - the container to pause
523
+ * @param record - the record for the container
524
+ */
525
+ private async pauseContainer(container: IContainer, record: ContainerRecord) {
526
+ debugWait(`${record.index}: pausing container`);
527
+ assert(!container.deltaManager.outbound.paused, "Container should not be paused yet");
528
+ assert(!container.deltaManager.inbound.paused, "Container should not be paused yet");
529
+
530
+ // Pause outbound
531
+ debugWait(`${record.index}: pausing container outbound queues`);
532
+ await container.deltaManager.outbound.pause();
533
+
534
+ // Ensure the container is connected first.
535
+ if (container.connectionState !== ConnectionState.Connected) {
536
+ debugWait(`${record.index}: Wait for container connection`);
537
+ await waitForContainerConnection(container);
538
+ }
539
+
540
+ // Check if the container is in write mode
541
+ if (!container.deltaManager.active) {
542
+ let proposalP: Promise<boolean> | undefined;
543
+ if (container.deltaManager.outbound.idle) {
544
+ // Need to generate an op to force write mode
545
+ debugWait(`${record.index}: container force write connection`);
546
+ const maybeContainer = container as Partial<IContainer>;
547
+ const codeProposal = maybeContainer.getLoadedCodeDetails
548
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
549
+ container.getLoadedCodeDetails()!
550
+ : (container as any).chaincodePackage;
551
+
552
+ proposalP = container.proposeCodeDetails(codeProposal);
553
+ }
554
+
555
+ // Wait for nack
556
+ debugWait(`${record.index}: Wait for container disconnect`);
557
+ container.deltaManager.outbound.resume();
558
+ await new Promise<void>((resolve) => container.once("disconnected", resolve));
559
+ const accepted = proposalP ? await proposalP : false;
560
+ assert(!accepted, "A proposal in read mode should be rejected");
561
+ await container.deltaManager.outbound.pause();
562
+
563
+ // Ensure the container is reconnect.
564
+ if (container.connectionState !== ConnectionState.Connected) {
565
+ debugWait(`${record.index}: Wait for container reconnection`);
566
+ await waitForContainerConnection(container);
567
+ }
568
+ }
569
+
570
+ debugWait(`${record.index}: pausing container inbound queues`);
571
+
572
+ // Pause inbound
573
+ await container.deltaManager.inbound.pause();
574
+
575
+ debugWait(`${record.index}: container paused`);
576
+
577
+ record.pauseP = undefined;
578
+ record.paused = true;
579
+ }
580
+
581
+ /**
582
+ * Pause all queue activities on all tracked containers, and resume only
583
+ * inbound to process ops until it is idle. All queues are left in the paused state
584
+ * after the function.
585
+ *
586
+ * Pausing will switch the container to write mode. See `pauseProcessing`
587
+ */
588
+ public async processIncoming(...containers: IContainer[]) {
589
+ return this.processQueue(containers, (container) => container.deltaManager.inbound);
590
+ }
591
+
592
+ /**
593
+ * Pause all queue activities on all tracked containers, and resume only
594
+ * outbound to process ops until it is idle. All queues are left in the paused state
595
+ * after the function.
596
+ *
597
+ * Pausing will switch the container to write mode. See `pauseProcessing`
598
+ */
599
+ public async processOutgoing(...containers: IContainer[]) {
600
+ return this.processQueue(containers, (container) => container.deltaManager.outbound);
601
+ }
602
+
603
+ /**
604
+ * Implementation of processIncoming and processOutgoing
605
+ */
606
+ private async processQueue<U>(
607
+ containers: IContainer[],
608
+ getQueue: (container: IContainer) => IDeltaQueue<U>,
609
+ ) {
610
+ await this.pauseProcessing(...containers);
611
+ const resumed: IDeltaQueue<U>[] = [];
612
+
613
+ const containersToApply = this.getContainers(containers);
614
+
615
+ const inflightTracker = new Map<IContainer, number>();
616
+ const cleanup: (() => void)[] = [];
617
+ for (const container of containersToApply) {
618
+ assert(
619
+ container.deltaManager.active,
620
+ "Container should be connected in write mode already",
621
+ );
622
+
623
+ const queue = getQueue(container);
624
+
625
+ // track the outgoing ops (if any) to make sure they make the round trip to at least to the same client
626
+ // to make sure they are sequenced.
627
+ cleanup.push(this.setupInOutTracker(container, inflightTracker));
628
+ queue.resume();
629
+ resumed.push(queue);
630
+ }
631
+
632
+ while (resumed.some((queue) => !queue.idle)) {
633
+ debugWait("Wait until queue is idle");
634
+ await new Promise<void>((resolve) => {
635
+ setTimeout(resolve, 0);
636
+ });
637
+ }
638
+
639
+ // Make sure all the op that we sent out are acked first
640
+ // This is no op if we are processing incoming
641
+ if (inflightTracker.size) {
642
+ debugWait("Wait for inflight ops");
643
+ do {
644
+ await this.waitForAnyInboundOps(containersToApply);
645
+ } while (inflightTracker.size);
646
+ }
647
+
648
+ // remove the handlers
649
+ cleanup.forEach((clean) => clean());
650
+
651
+ await Promise.all(resumed.map(async (queue) => queue.pause()));
652
+ }
653
+
654
+ /**
655
+ * Utility to set up listener to track the outbound ops until it round trip back
656
+ * Returns a function to remove the handler after it is done.
657
+ *
658
+ * @param container - the container to setup
659
+ * @param inflightTracker - a map to track the clientSequenceNumber per container it expect to get ops back
660
+ */
661
+ private setupInOutTracker(container: IContainer, inflightTracker: Map<IContainer, number>) {
662
+ const outHandler = (messages: IDocumentMessage[]) => {
663
+ for (const message of messages) {
664
+ if (!canBeCoalescedByService(message)) {
665
+ inflightTracker.set(container, message.clientSequenceNumber);
666
+ }
667
+ }
668
+ };
669
+ const inHandler = (message: ISequencedDocumentMessage) => {
670
+ if (
671
+ !canBeCoalescedByService(message) &&
672
+ message.clientId === container.clientId &&
673
+ inflightTracker.get(container) === message.clientSequenceNumber
674
+ ) {
675
+ inflightTracker.delete(container);
676
+ }
677
+ };
678
+
679
+ container.deltaManager.outbound.on("op", outHandler);
680
+ container.deltaManager.inbound.on("push", inHandler);
681
+
682
+ return () => {
683
+ container.deltaManager.outbound.off("op", outHandler);
684
+ container.deltaManager.inbound.off("push", inHandler);
685
+ };
686
+ }
687
+
688
+ /**
689
+ * Setup debug traces for connection and ops
690
+ */
691
+ private setupTrace(container: IContainer, index: number) {
692
+ if (debugOp.enabled) {
693
+ const getContentsString = (type: string, msgContents: any) => {
694
+ try {
695
+ if (type !== MessageType.Operation) {
696
+ if (typeof msgContents === "string") {
697
+ return msgContents;
698
+ }
699
+ return JSON.stringify(msgContents);
700
+ }
701
+ let address = "";
702
+
703
+ // contents comes in the wire as JSON string ("push" event)
704
+ // But already parsed when apply ("op" event)
705
+ let contents =
706
+ typeof msgContents === "string" ? JSON.parse(msgContents) : msgContents;
707
+ while (contents !== undefined && contents !== null) {
708
+ if (contents.contents?.address !== undefined) {
709
+ address += `/${contents.contents.address}`;
710
+ contents = contents.contents.contents;
711
+ } else if (contents.content?.address !== undefined) {
712
+ address += `/${contents.content.address}`;
713
+ contents = contents.content.contents;
714
+ } else {
715
+ break;
716
+ }
717
+ }
718
+ if (address) {
719
+ return `${address} ${JSON.stringify(contents)}`;
720
+ }
721
+ return JSON.stringify(contents);
722
+ } catch (e: any) {
723
+ return `${e.message}: ${e.stack}`;
724
+ }
725
+ };
726
+ debugOp(`${index}: ADD: clientId: ${container.clientId}`);
727
+ container.deltaManager.outbound.on("op", (messages) => {
728
+ for (const msg of messages) {
729
+ debugOp(
730
+ `${index}: OUT: ` +
731
+ `cli: ${msg.clientSequenceNumber.toString().padStart(3)} ` +
732
+ `rsq: ${msg.referenceSequenceNumber.toString().padStart(3)} ` +
733
+ `${msg.type} ${getContentsString(msg.type, msg.contents)}`,
734
+ );
735
+ }
736
+ });
737
+ const getInboundHandler = (type: string) => {
738
+ return (msg: ISequencedDocumentMessage) => {
739
+ const clientSeq =
740
+ msg.clientId === container.clientId
741
+ ? `cli: ${msg.clientSequenceNumber.toString().padStart(3)}`
742
+ : " ";
743
+ debugOp(
744
+ `${index}: ${type}: seq: ${msg.sequenceNumber.toString().padStart(3)} ` +
745
+ `${clientSeq} min: ${msg.minimumSequenceNumber
746
+ .toString()
747
+ .padStart(3)} ` +
748
+ `${msg.type} ${getContentsString(msg.type, msg.contents)}`,
749
+ );
750
+ };
751
+ };
752
+ container.deltaManager.inbound.on("push", getInboundHandler("IN "));
753
+ container.deltaManager.inbound.on("op", getInboundHandler("OP "));
754
+ container.deltaManager.on("connect", (details) => {
755
+ debugOp(`${index}: CON: clientId: ${details.clientId}`);
756
+ });
757
+ container.deltaManager.on("disconnect", (reason) => {
758
+ debugOp(`${index}: DIS: ${reason}`);
759
+ });
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Filter out the opened containers based on param.
765
+ * @param containers - The container to filter to. If the array is empty, it means don't filter and return
766
+ * all open containers.
767
+ */
768
+ private getContainers(containers: IContainer[]) {
769
+ const containersToApply =
770
+ containers.length === 0 ? Array.from(this.containers.keys()) : containers;
771
+ return containersToApply.filter((container) => !container.closed);
772
+ }
553
773
  }