@fluidframework/test-utils 2.0.0-internal.4.0.5 → 2.0.0-internal.4.1.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.
@@ -11,6 +11,7 @@ import {
11
11
  ISequencedDocumentMessage,
12
12
  MessageType,
13
13
  } from "@fluidframework/protocol-definitions";
14
+ import { waitForContainerConnection } from "./containerUtils";
14
15
  import { debug } from "./debug";
15
16
  import { IOpProcessingController } from "./testObjectProvider";
16
17
  import { timeoutAwait, timeoutPromise } from "./timeoutUtils";
@@ -24,6 +25,7 @@ interface ContainerRecord {
24
25
 
25
26
  // LoaderContainerTracker paused state
26
27
  paused: boolean;
28
+ pauseP?: Promise<void>; // promise for for the pause that is in progress
27
29
 
28
30
  // Tracking trailing no-op that may or may be acked by the server so we can discount them
29
31
  // See issue #5629
@@ -162,57 +164,51 @@ export class LoaderContainerTracker implements IOpProcessingController {
162
164
  // REVIEW: do we need to unpatch the loaders?
163
165
  }
164
166
 
165
- /**
166
- * Ensure all tracked containers are synchronized
167
- */
168
- public async ensureSynchronized(...containers: IContainer[]): Promise<void> {
169
- await this.processSynchronized(undefined, ...containers);
170
- }
171
-
172
167
  /**
173
168
  * Ensure all tracked containers are synchronized with a time limit
169
+ *
170
+ * @deprecated - this method is equivalent to @see {@link LoaderContainerTracker.ensureSynchronized}, please configure the test timeout instead
174
171
  */
175
172
  public async ensureSynchronizedWithTimeout?(
176
173
  timeoutDuration: number | undefined,
177
174
  ...containers: IContainer[]
178
175
  ) {
179
- await this.processSynchronized(timeoutDuration, ...containers);
176
+ await this.ensureSynchronized(...containers);
180
177
  }
181
178
 
182
179
  /**
183
180
  * Make sure all the tracked containers are synchronized.
184
181
  *
185
182
  * No isDirty (non-readonly) containers
186
- *
187
183
  * No extra clientId in quorum of any container that is not tracked and still opened.
188
- *
189
184
  * - i.e. no pending Join/Leave message.
190
- *
191
185
  * No unresolved proposal (minSeqNum \>= lastProposalSeqNum)
192
- *
193
186
  * lastSequenceNumber of all container is the same
194
- *
195
187
  * clientSequenceNumberObserved is the same as clientSequenceNumber sent
196
- *
197
188
  * - this overlaps with !isDirty, but include task scheduler ops.
198
- *
199
189
  * - Trailing NoOp is tracked and don't count as pending ops.
190
+ *
191
+ * Containers that are already pause will resume process and paused again once
192
+ * everything is synchronized. Containers that aren't paused will remain unpaused when this
193
+ * function returns.
200
194
  */
201
- private async processSynchronized(
202
- timeoutDuration: number | undefined,
203
- ...containers: IContainer[]
204
- ) {
195
+ public async ensureSynchronized(...containers: IContainer[]): Promise<void> {
205
196
  const resumed = this.resumeProcessing(...containers);
206
197
 
207
- let waitingSequenceNumberSynchronized = false;
198
+ let waitingSequenceNumberSynchronized: string | undefined;
208
199
  // eslint-disable-next-line no-constant-condition
209
200
  while (true) {
201
+ // yield a turn to allow side effect of resuming or the ops we just processed execute before we check
202
+ await new Promise<void>((resolve) => {
203
+ setTimeout(resolve, 0);
204
+ });
205
+
210
206
  const containersToApply = this.getContainers(containers);
211
207
  if (containersToApply.length === 0) {
212
208
  break;
213
209
  }
214
210
 
215
- // Ignore readonly dirty containers, because it can't sent up and nothing can be done about it being dirty
211
+ // Ignore readonly dirty containers, because it can't sent ops and nothing can be done about it being dirty
216
212
  const dirtyContainers = containersToApply.filter((c) => {
217
213
  const { deltaManager, isDirty } = c;
218
214
  return deltaManager.readOnlyInfo.readonly !== true && isDirty;
@@ -221,20 +217,22 @@ export class LoaderContainerTracker implements IOpProcessingController {
221
217
  // Wait for all the leave messages
222
218
  const pendingClients = this.getPendingClients(containersToApply);
223
219
  if (pendingClients.length === 0) {
224
- if (this.isSequenceNumberSynchronized(containersToApply)) {
220
+ const needSync = this.needSequenceNumberSynchronize(containersToApply);
221
+ if (needSync === undefined) {
225
222
  // done, we are in sync
226
223
  break;
227
224
  }
228
- if (!waitingSequenceNumberSynchronized) {
229
- // Only write it out once
230
- waitingSequenceNumberSynchronized = true;
231
- debugWait("Waiting for sequence number synchronized");
232
- await timeoutAwait(this.waitForAnyInboundOps(containersToApply), {
233
- errorMsg: "Timeout on waiting for sequence number synchronized",
234
- });
225
+ if (waitingSequenceNumberSynchronized !== needSync.reason) {
226
+ // Don't repeat writing to console if it is the same reason
227
+ waitingSequenceNumberSynchronized = needSync.reason;
228
+ debugWait(needSync.message);
235
229
  }
230
+ // Wait for one inbounds ops which might change the state of things
231
+ await timeoutAwait(this.waitForAnyInboundOps(containersToApply), {
232
+ errorMsg: `Timeout on ${needSync.message}`,
233
+ });
236
234
  } else {
237
- waitingSequenceNumberSynchronized = false;
235
+ waitingSequenceNumberSynchronized = undefined;
238
236
  await timeoutAwait(this.waitForPendingClients(pendingClients), {
239
237
  errorMsg: "Timeout on waiting for pending join or leave op",
240
238
  });
@@ -242,12 +240,9 @@ export class LoaderContainerTracker implements IOpProcessingController {
242
240
  } else {
243
241
  // Wait for all the containers to be saved
244
242
  debugWait(
245
- `Waiting container to be saved ${dirtyContainers.map(
246
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
247
- (c) => this.containers.get(c)!.index,
248
- )}`,
243
+ `Waiting container to be saved ${this.containerIndexStrings(dirtyContainers)}`,
249
244
  );
250
- waitingSequenceNumberSynchronized = false;
245
+ waitingSequenceNumberSynchronized = undefined;
251
246
  await Promise.all(
252
247
  dirtyContainers.map(async (c) =>
253
248
  Promise.race([
@@ -259,11 +254,6 @@ export class LoaderContainerTracker implements IOpProcessingController {
259
254
  ),
260
255
  );
261
256
  }
262
-
263
- // yield a turn to allow side effect of the ops we just processed execute before we check again
264
- await new Promise<void>((resolve) => {
265
- setTimeout(resolve, 0);
266
- });
267
257
  }
268
258
 
269
259
  // Pause all container that was resumed
@@ -319,48 +309,78 @@ export class LoaderContainerTracker implements IOpProcessingController {
319
309
  *
320
310
  * @param containersToApply - the set of containers to check
321
311
  */
322
- private isSequenceNumberSynchronized(containersToApply: IContainer[]) {
312
+ private needSequenceNumberSynchronize(containersToApply: IContainer[]) {
313
+ // If there is a pending proposal, wait for it to be accepted
314
+ const minSeqNum = containersToApply[0].deltaManager.minimumSequenceNumber;
315
+ if (minSeqNum < this.lastProposalSeqNum) {
316
+ return {
317
+ reason: "Proposal",
318
+ message: `waiting for MSN to advance to proposal at sequence number ${this.lastProposalSeqNum}`,
319
+ };
320
+ }
321
+
323
322
  // clientSequenceNumber check detects ops in flight, both on the wire and in the outbound queue
324
323
  // We need both client sequence number and isDirty check because:
325
324
  // - Currently isDirty flag ignores ops for task scheduler, so we need the client sequence number check
326
325
  // - But isDirty flags include ops during forceReadonly and disconnected, because we don't submit
327
326
  // the ops in the first place, clientSequenceNumber is not assigned
328
327
 
329
- const isClientSequenceNumberSynchronized = containersToApply.every((container) => {
328
+ const containerWithInflightOps = containersToApply.filter((container) => {
330
329
  if (container.deltaManager.readOnlyInfo.readonly === true) {
331
330
  // Ignore readonly container. the clientSeqNum and clientSeqNumObserved might be out of sync
332
331
  // because we transition to readonly when outbound is not empty or the in transit op got lost
333
- return true;
332
+ return false;
334
333
  }
335
334
  // Note that in read only mode, the op won't be submitted
336
335
  let deltaManager = container.deltaManager as any;
337
336
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
338
337
  const { trailingNoOps } = this.containers.get(container)!;
339
- // Back-compat: clientSequenceNumber & clientSequenceNumberObserved moved to ConnectionManager in 0.53
338
+ // Back-compat: lastSubmittedClientId/clientSequenceNumber/clientSequenceNumberObserved moved to ConnectionManager in 0.53
340
339
  if (!("clientSequenceNumber" in deltaManager)) {
341
340
  deltaManager = deltaManager.connectionManager;
342
341
  }
343
342
  assert("clientSequenceNumber" in deltaManager, "no clientSequenceNumber");
344
343
  assert("clientSequenceNumberObserved" in deltaManager, "no clientSequenceNumber");
344
+ // If last submittedClientId isn't the current clientId, then we haven't send any ops
345
345
  return (
346
- deltaManager.clientSequenceNumber ===
347
- (deltaManager.clientSequenceNumberObserved as number) + trailingNoOps
346
+ deltaManager.lastSubmittedClientId === container.clientId &&
347
+ deltaManager.clientSequenceNumber !==
348
+ (deltaManager.clientSequenceNumberObserved as number) + trailingNoOps
348
349
  );
349
350
  });
350
351
 
351
- if (!isClientSequenceNumberSynchronized) {
352
- return false;
352
+ if (containerWithInflightOps.length !== 0) {
353
+ return {
354
+ reason: "InflightOps",
355
+ message: `waiting for containers with inflight ops: ${this.containerIndexStrings(
356
+ containerWithInflightOps,
357
+ )}`,
358
+ };
353
359
  }
354
360
 
355
- const minSeqNum = containersToApply[0].deltaManager.minimumSequenceNumber;
356
- if (minSeqNum < this.lastProposalSeqNum) {
357
- // There is an unresolved proposal
358
- return false;
361
+ // Check to see if all the container has process the same number of ops.
362
+ const maxSeqNum = Math.max(
363
+ ...containersToApply.map((c) => c.deltaManager.lastSequenceNumber),
364
+ );
365
+ const containerWithPendingIncoming = containersToApply.filter(
366
+ (c) => c.deltaManager.lastSequenceNumber !== maxSeqNum,
367
+ );
368
+ if (containerWithPendingIncoming.length !== 0) {
369
+ return {
370
+ reason: "Pending",
371
+ message: `waiting for containers with pending incoming ops up to sequence number ${maxSeqNum}: ${this.containerIndexStrings(
372
+ containerWithPendingIncoming,
373
+ )}`,
374
+ };
359
375
  }
376
+ return undefined;
377
+ }
360
378
 
361
- // Check to see if all the container has process the same number of ops.
362
- const seqNum = containersToApply[0].deltaManager.lastSequenceNumber;
363
- return containersToApply.every((c) => c.deltaManager.lastSequenceNumber === seqNum);
379
+ private containerIndexStrings(containers: IContainer[]) {
380
+ return containers.map(
381
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
382
+ (c) => this.containers.get(c)!.index,
383
+ );
364
384
  }
365
385
 
366
386
  /**
@@ -433,6 +453,10 @@ export class LoaderContainerTracker implements IOpProcessingController {
433
453
  const containersToApply = this.getContainers(containers);
434
454
  for (const container of containersToApply) {
435
455
  const record = this.containers.get(container);
456
+ assert(
457
+ record?.pauseP === undefined,
458
+ "Cannot resume container while pausing is in progress",
459
+ );
436
460
  if (record?.paused === true) {
437
461
  debugWait(`${record.index}: container resumed`);
438
462
  container.deltaManager.inbound.resume();
@@ -447,26 +471,98 @@ export class LoaderContainerTracker implements IOpProcessingController {
447
471
  /**
448
472
  * Pause all queue activities on the containers given, or all tracked containers
449
473
  * Any containers given that is not tracked will be ignored.
474
+ *
475
+ * When a container is paused, it is assumed that we want fine grain control over op
476
+ * sequencing. This function will prepare the container and force it into write mode to
477
+ * avoid missing join messages or change the sequence of event when switching from read to
478
+ * write mode.
450
479
  */
451
480
  public async pauseProcessing(...containers: IContainer[]) {
452
- const pauseP: Promise<void>[] = [];
481
+ const waitP: Promise<void>[] = [];
453
482
  const containersToApply = this.getContainers(containers);
454
483
  for (const container of containersToApply) {
455
484
  const record = this.containers.get(container);
456
485
  if (record !== undefined && !record.paused) {
457
- debugWait(`${record.index}: container paused`);
458
- pauseP.push(container.deltaManager.inbound.pause());
459
- pauseP.push(container.deltaManager.outbound.pause());
460
- record.paused = true;
486
+ if (record.pauseP === undefined) {
487
+ record.pauseP = this.pauseContainer(container, record);
488
+ }
489
+ waitP.push(record.pauseP);
461
490
  }
462
491
  }
463
- await Promise.all(pauseP);
492
+ await Promise.all(waitP);
493
+ }
494
+
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
+ * @param container - the container to pause
502
+ * @param record - the record for the container
503
+ */
504
+ private async pauseContainer(container: IContainer, record: ContainerRecord) {
505
+ debugWait(`${record.index}: pausing container`);
506
+ assert(!container.deltaManager.outbound.paused, "Container should not be paused yet");
507
+ assert(!container.deltaManager.inbound.paused, "Container should not be paused yet");
508
+
509
+ // Pause outbound
510
+ debugWait(`${record.index}: pausing container outbound queues`);
511
+ await container.deltaManager.outbound.pause();
512
+
513
+ // Ensure the container is connected first.
514
+ if (container.connectionState !== ConnectionState.Connected) {
515
+ debugWait(`${record.index}: Wait for container connection`);
516
+ await waitForContainerConnection(container);
517
+ }
518
+
519
+ // Check if the container is in write mode
520
+ if (!container.deltaManager.active) {
521
+ let proposalP: Promise<boolean> | undefined;
522
+ if (container.deltaManager.outbound.idle) {
523
+ // Need to generate an op to force write mode
524
+ debugWait(`${record.index}: container force write connection`);
525
+ const maybeContainer = container as Partial<IContainer>;
526
+ const codeProposal = maybeContainer.getLoadedCodeDetails
527
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
528
+ container.getLoadedCodeDetails()!
529
+ : (container as any).chaincodePackage;
530
+
531
+ proposalP = container.proposeCodeDetails(codeProposal);
532
+ }
533
+
534
+ // Wait for nack
535
+ debugWait(`${record.index}: Wait for container disconnect`);
536
+ container.deltaManager.outbound.resume();
537
+ await new Promise<void>((resolve) => container.once("disconnected", resolve));
538
+ const accepted = proposalP ? await proposalP : false;
539
+ assert(!accepted, "A proposal in read mode should be rejected");
540
+ await container.deltaManager.outbound.pause();
541
+
542
+ // Ensure the container is reconnect.
543
+ if (container.connectionState !== ConnectionState.Connected) {
544
+ debugWait(`${record.index}: Wait for container reconnection`);
545
+ await waitForContainerConnection(container);
546
+ }
547
+ }
548
+
549
+ debugWait(`${record.index}: pausing container inbound queues`);
550
+
551
+ // Pause inbound
552
+ await container.deltaManager.inbound.pause();
553
+
554
+ debugWait(`${record.index}: container paused`);
555
+
556
+ record.pauseP = undefined;
557
+ record.paused = true;
464
558
  }
465
559
 
466
560
  /**
467
561
  * Pause all queue activities on all tracked containers, and resume only
468
562
  * inbound to process ops until it is idle. All queues are left in the paused state
469
- * after the function
563
+ * after the function.
564
+ *
565
+ * Pausing will switch the container to write mode. See `pauseProcessing`
470
566
  */
471
567
  public async processIncoming(...containers: IContainer[]) {
472
568
  return this.processQueue(containers, (container) => container.deltaManager.inbound);
@@ -475,7 +571,9 @@ export class LoaderContainerTracker implements IOpProcessingController {
475
571
  /**
476
572
  * Pause all queue activities on all tracked containers, and resume only
477
573
  * outbound to process ops until it is idle. All queues are left in the paused state
478
- * after the function
574
+ * after the function.
575
+ *
576
+ * Pausing will switch the container to write mode. See `pauseProcessing`
479
577
  */
480
578
  public async processOutgoing(...containers: IContainer[]) {
481
579
  return this.processQueue(containers, (container) => container.deltaManager.outbound);
@@ -492,9 +590,15 @@ export class LoaderContainerTracker implements IOpProcessingController {
492
590
  const resumed: IDeltaQueue<U>[] = [];
493
591
 
494
592
  const containersToApply = this.getContainers(containers);
593
+
495
594
  const inflightTracker = new Map<IContainer, number>();
496
595
  const cleanup: (() => void)[] = [];
497
596
  for (const container of containersToApply) {
597
+ assert(
598
+ container.deltaManager.active,
599
+ "Container should be connected in write mode already",
600
+ );
601
+
498
602
  const queue = getQueue(container);
499
603
 
500
604
  // track the outgoing ops (if any) to make sure they make the round trip to at least to the same client
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  export const pkgName = "@fluidframework/test-utils";
9
- export const pkgVersion = "2.0.0-internal.4.0.5";
9
+ export const pkgVersion = "2.0.0-internal.4.1.0";
@@ -3,7 +3,13 @@
3
3
  * Licensed under the MIT License.
4
4
  */
5
5
 
6
- import { IContainer, IHostLoader, IFluidCodeDetails } from "@fluidframework/container-definitions";
6
+ import {
7
+ IContainer,
8
+ IHostLoader,
9
+ IFluidCodeDetails,
10
+ LoaderHeader,
11
+ ILoader,
12
+ } from "@fluidframework/container-definitions";
7
13
  import {
8
14
  ITelemetryGenericEvent,
9
15
  ITelemetryBaseLogger,
@@ -107,6 +113,9 @@ export interface ITestContainerConfig {
107
113
 
108
114
  /** Loader options for the loader used to create containers */
109
115
  loaderProps?: Partial<ILoaderProps>;
116
+
117
+ /** Temporary flag: simulate read connection using delay connection, default is true */
118
+ simulateReadConnectionUsingDelay?: boolean;
110
119
  }
111
120
 
112
121
  export const createDocumentId = (): string => uuid();
@@ -377,10 +386,44 @@ export class TestObjectProvider implements ITestObjectProvider {
377
386
  requestHeader?: IRequestHeader,
378
387
  ): Promise<IContainer> {
379
388
  const loader = this.createLoader([[defaultCodeDetails, entryPoint]], loaderProps);
380
- return loader.resolve({
389
+ return this.resolveContainer(loader, requestHeader);
390
+ }
391
+
392
+ private async resolveContainer(
393
+ loader: ILoader,
394
+ requestHeader?: IRequestHeader,
395
+ delay: boolean = true,
396
+ ) {
397
+ // Once AB#3889 is done to switch default connection mode to "read" on load, we don't need
398
+ // to load "delayed" across the board. Remove the following code.
399
+ const delayConnection =
400
+ delay &&
401
+ (requestHeader === undefined || requestHeader[LoaderHeader.reconnect] !== false);
402
+ const headers: IRequestHeader | undefined = delayConnection
403
+ ? {
404
+ [LoaderHeader.loadMode]: { deltaConnection: "delayed" },
405
+ ...requestHeader,
406
+ }
407
+ : requestHeader;
408
+
409
+ const container = await loader.resolve({
381
410
  url: await this.driver.createContainerUrl(this.documentId),
382
- headers: requestHeader,
411
+ headers,
383
412
  });
413
+
414
+ // Once AB#3889 is done to switch default connection mode to "read" on load, we don't need
415
+ // to load "delayed" across the board. Remove the following code.
416
+ if (delayConnection) {
417
+ // Older version may not have connect/disconnect. It was add in PR#9439, and available >= 0.59.1000
418
+ const maybeContainer = container as Partial<IContainer>;
419
+ if (maybeContainer.connect !== undefined) {
420
+ container.connect();
421
+ } else {
422
+ // back compat. Remove when we don't support < 0.59.1000
423
+ (container as any).resume();
424
+ }
425
+ }
426
+ return container;
384
427
  }
385
428
 
386
429
  /**
@@ -432,10 +475,12 @@ export class TestObjectProvider implements ITestObjectProvider {
432
475
  requestHeader?: IRequestHeader,
433
476
  ): Promise<IContainer> {
434
477
  const loader = this.makeTestLoader(testContainerConfig);
435
- const container = await loader.resolve({
436
- url: await this.driver.createContainerUrl(this.documentId),
437
- headers: requestHeader,
438
- });
478
+
479
+ const container = await this.resolveContainer(
480
+ loader,
481
+ requestHeader,
482
+ testContainerConfig?.simulateReadConnectionUsingDelay,
483
+ );
439
484
  await this.waitContainerToCatchUp(container);
440
485
 
441
486
  return container;
@@ -455,9 +500,7 @@ export class TestObjectProvider implements ITestObjectProvider {
455
500
  }
456
501
 
457
502
  public async ensureSynchronized(timeoutDuration?: number): Promise<void> {
458
- return this._loaderContainerTracker.ensureSynchronizedWithTimeout
459
- ? this._loaderContainerTracker.ensureSynchronizedWithTimeout(timeoutDuration)
460
- : this._loaderContainerTracker.ensureSynchronized();
503
+ return this._loaderContainerTracker.ensureSynchronized();
461
504
  }
462
505
 
463
506
  public async waitContainerToCatchUp(container: IContainer) {