@fluidframework/container-runtime 2.41.0-338401 → 2.41.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/container-runtime.test-files.tar +0 -0
  3. package/dist/containerRuntime.d.ts +35 -17
  4. package/dist/containerRuntime.d.ts.map +1 -1
  5. package/dist/containerRuntime.js +174 -127
  6. package/dist/containerRuntime.js.map +1 -1
  7. package/dist/opLifecycle/batchManager.d.ts +4 -0
  8. package/dist/opLifecycle/batchManager.d.ts.map +1 -1
  9. package/dist/opLifecycle/batchManager.js +7 -0
  10. package/dist/opLifecycle/batchManager.js.map +1 -1
  11. package/dist/opLifecycle/outbox.d.ts +1 -0
  12. package/dist/opLifecycle/outbox.d.ts.map +1 -1
  13. package/dist/opLifecycle/outbox.js +6 -1
  14. package/dist/opLifecycle/outbox.js.map +1 -1
  15. package/dist/packageVersion.d.ts +1 -1
  16. package/dist/packageVersion.d.ts.map +1 -1
  17. package/dist/packageVersion.js +1 -1
  18. package/dist/packageVersion.js.map +1 -1
  19. package/dist/pendingStateManager.d.ts +4 -0
  20. package/dist/pendingStateManager.d.ts.map +1 -1
  21. package/dist/pendingStateManager.js +16 -0
  22. package/dist/pendingStateManager.js.map +1 -1
  23. package/dist/runCounter.d.ts.map +1 -1
  24. package/dist/runCounter.js +1 -1
  25. package/dist/runCounter.js.map +1 -1
  26. package/lib/containerRuntime.d.ts +35 -17
  27. package/lib/containerRuntime.d.ts.map +1 -1
  28. package/lib/containerRuntime.js +174 -128
  29. package/lib/containerRuntime.js.map +1 -1
  30. package/lib/opLifecycle/batchManager.d.ts +4 -0
  31. package/lib/opLifecycle/batchManager.d.ts.map +1 -1
  32. package/lib/opLifecycle/batchManager.js +7 -0
  33. package/lib/opLifecycle/batchManager.js.map +1 -1
  34. package/lib/opLifecycle/outbox.d.ts +1 -0
  35. package/lib/opLifecycle/outbox.d.ts.map +1 -1
  36. package/lib/opLifecycle/outbox.js +6 -1
  37. package/lib/opLifecycle/outbox.js.map +1 -1
  38. package/lib/packageVersion.d.ts +1 -1
  39. package/lib/packageVersion.d.ts.map +1 -1
  40. package/lib/packageVersion.js +1 -1
  41. package/lib/packageVersion.js.map +1 -1
  42. package/lib/pendingStateManager.d.ts +4 -0
  43. package/lib/pendingStateManager.d.ts.map +1 -1
  44. package/lib/pendingStateManager.js +16 -0
  45. package/lib/pendingStateManager.js.map +1 -1
  46. package/lib/runCounter.d.ts.map +1 -1
  47. package/lib/runCounter.js +1 -1
  48. package/lib/runCounter.js.map +1 -1
  49. package/package.json +18 -18
  50. package/src/containerRuntime.ts +263 -152
  51. package/src/opLifecycle/batchManager.ts +8 -0
  52. package/src/opLifecycle/outbox.ts +8 -1
  53. package/src/packageVersion.ts +1 -1
  54. package/src/pendingStateManager.ts +17 -0
  55. package/src/runCounter.ts +4 -1
@@ -7,7 +7,7 @@ import type {
7
7
  ILayerCompatDetails,
8
8
  IProvideLayerCompatDetails,
9
9
  } from "@fluid-internal/client-utils";
10
- import { Trace, TypedEventEmitter } from "@fluid-internal/client-utils";
10
+ import { createEmitter, Trace, TypedEventEmitter } from "@fluid-internal/client-utils";
11
11
  import type {
12
12
  IAudience,
13
13
  ISelf,
@@ -25,8 +25,15 @@ import type {
25
25
  } from "@fluidframework/container-definitions/internal";
26
26
  import { isIDeltaManagerFull } from "@fluidframework/container-definitions/internal";
27
27
  import type {
28
+ ContainerExtensionFactory,
29
+ ContainerExtensionId,
30
+ ExtensionHost,
31
+ ExtensionHostEvents,
32
+ ExtensionRuntimeProperties,
28
33
  IContainerRuntime,
29
34
  IContainerRuntimeEvents,
35
+ IContainerRuntimeInternal,
36
+ OutboundExtensionMessage,
30
37
  } from "@fluidframework/container-runtime-definitions/internal";
31
38
  import type {
32
39
  FluidObject,
@@ -34,6 +41,7 @@ import type {
34
41
  IRequest,
35
42
  IResponse,
36
43
  ITelemetryBaseLogger,
44
+ Listenable,
37
45
  } from "@fluidframework/core-interfaces";
38
46
  import type {
39
47
  IErrorBase,
@@ -41,13 +49,17 @@ import type {
41
49
  IFluidHandleInternal,
42
50
  IProvideFluidHandleContext,
43
51
  ISignalEnvelope,
52
+ JsonDeserialized,
53
+ TypedMessage,
44
54
  } from "@fluidframework/core-interfaces/internal";
45
55
  import {
46
56
  assert,
47
57
  Deferred,
58
+ Lazy,
48
59
  LazyPromise,
49
60
  PromiseCache,
50
61
  delay,
62
+ fail,
51
63
  } from "@fluidframework/core-utils/internal";
52
64
  import type {
53
65
  IClientDetails,
@@ -284,6 +296,16 @@ import {
284
296
  } from "./summary/index.js";
285
297
  import { Throttler, formExponentialFn } from "./throttler.js";
286
298
 
299
+ /**
300
+ * A {@link ContainerExtension}'s factory function as stored in extension map.
301
+ */
302
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `any` required to allow typed factory to be assignable per ContainerExtension.processSignal
303
+ type ExtensionEntry = ContainerExtensionFactory<unknown, any, unknown[]> extends new (
304
+ ...args: any[]
305
+ ) => infer T
306
+ ? T
307
+ : never;
308
+
287
309
  /**
288
310
  * Creates an error object to be thrown / passed to Container's close fn in case of an unknown message type.
289
311
  * The parameters are typed to support compile-time enforcement of handling all known types/behaviors
@@ -674,6 +696,8 @@ export let getSingleUseLegacyLogCallback = (logger: ITelemetryLoggerExt, type: s
674
696
  };
675
697
  };
676
698
 
699
+ type UnsequencedSignalEnvelope = Omit<ISignalEnvelope, "clientBroadcastSignalSequenceNumber">;
700
+
677
701
  /**
678
702
  * This object holds the parameters necessary for the {@link loadContainerRuntime} function.
679
703
  * @legacy
@@ -757,7 +781,7 @@ const defaultMaxConsecutiveReconnects = 7;
757
781
  export class ContainerRuntime
758
782
  extends TypedEventEmitter<IContainerRuntimeEvents>
759
783
  implements
760
- IContainerRuntime,
784
+ IContainerRuntimeInternal,
761
785
  // eslint-disable-next-line import/no-deprecated
762
786
  IContainerRuntimeBaseExperimental,
763
787
  IRuntime,
@@ -1148,10 +1172,10 @@ export class ContainerRuntime
1148
1172
  summaryOp: ISummaryContent,
1149
1173
  referenceSequenceNumber?: number,
1150
1174
  ) => number;
1151
- /**
1152
- * Do not call directly - use submitAddressesSignal
1153
- */
1154
- private readonly submitSignalFn: (content: ISignalEnvelope, targetClientId?: string) => void;
1175
+ private readonly submitSignalFn: (
1176
+ content: UnsequencedSignalEnvelope,
1177
+ targetClientId?: string,
1178
+ ) => void;
1155
1179
  public readonly disposeFn: (error?: ICriticalContainerError) => void;
1156
1180
  public readonly closeFn: (error?: ICriticalContainerError) => void;
1157
1181
 
@@ -1266,7 +1290,7 @@ export class ContainerRuntime
1266
1290
  private readonly batchRunner = new BatchRunCounter();
1267
1291
  private readonly _flushMode: FlushMode;
1268
1292
  private readonly offlineEnabled: boolean;
1269
- private flushTaskExists = false;
1293
+ private flushScheduled = false;
1270
1294
 
1271
1295
  private _connected: boolean;
1272
1296
 
@@ -1282,7 +1306,7 @@ export class ContainerRuntime
1282
1306
 
1283
1307
  /**
1284
1308
  * Invokes the given callback and expects that no ops are submitted
1285
- * until execution finishes. If an op is submitted, an error will be raised.
1309
+ * until execution finishes. If an op is submitted, it will be marked as reentrant.
1286
1310
  *
1287
1311
  * @param callback - the callback to be invoked
1288
1312
  */
@@ -1306,7 +1330,7 @@ export class ContainerRuntime
1306
1330
  return this._disposed;
1307
1331
  }
1308
1332
 
1309
- private dirtyContainer: boolean;
1333
+ private lastEmittedDirty: boolean;
1310
1334
  private emitDirtyDocumentEvent = true;
1311
1335
  private readonly useDeltaManagerOpsProxy: boolean;
1312
1336
  private readonly closeSummarizerDelayMs: number;
@@ -1405,6 +1429,8 @@ export class ContainerRuntime
1405
1429
  */
1406
1430
  private readonly skipSafetyFlushDuringProcessStack: boolean;
1407
1431
 
1432
+ private readonly extensions = new Map<ContainerExtensionId, ExtensionEntry>();
1433
+
1408
1434
  /***/
1409
1435
  protected constructor(
1410
1436
  context: IContainerContext,
@@ -1498,7 +1524,35 @@ export class ContainerRuntime
1498
1524
  this.submitSummaryFn =
1499
1525
  submitSummaryFn ??
1500
1526
  ((summaryOp, refseq) => submitFn(MessageType.Summarize, summaryOp, false));
1501
- this.submitSignalFn = submitSignalFn;
1527
+
1528
+ const sequenceAndSubmitSignal = (
1529
+ envelope: UnsequencedSignalEnvelope,
1530
+ targetClientId?: string,
1531
+ ): void => {
1532
+ if (targetClientId === undefined) {
1533
+ this.signalTelemetryManager.applyTrackingToBroadcastSignalEnvelope(envelope);
1534
+ }
1535
+ submitSignalFn(envelope, targetClientId);
1536
+ };
1537
+ this.submitSignalFn = (envelope: UnsequencedSignalEnvelope, targetClientId?: string) => {
1538
+ if (envelope.address?.startsWith("/")) {
1539
+ throw new Error("General path based addressing is not implemented");
1540
+ }
1541
+ sequenceAndSubmitSignal(envelope, targetClientId);
1542
+ };
1543
+ this.submitExtensionSignal = <TMessage extends TypedMessage>(
1544
+ id: string,
1545
+ addressChain: string[],
1546
+ message: OutboundExtensionMessage<TMessage>,
1547
+ ): void => {
1548
+ this.verifyNotClosed();
1549
+ const envelope = createNewSignalEnvelope(
1550
+ `/ext/${id}/${addressChain.join("/")}`,
1551
+ message.type,
1552
+ message.content,
1553
+ );
1554
+ sequenceAndSubmitSignal(envelope, message.targetClientId);
1555
+ };
1502
1556
 
1503
1557
  // TODO: After IContainerContext.options is removed, we'll just create a new blank object {} here.
1504
1558
  // Values are generally expected to be set from the runtime side.
@@ -1532,8 +1586,8 @@ export class ContainerRuntime
1532
1586
  this.mc.logger.sendTelemetryEvent({
1533
1587
  eventName: "Attached",
1534
1588
  details: {
1535
- dirtyContainer: this.dirtyContainer,
1536
- hasPendingMessages: this.hasPendingMessages(),
1589
+ lastEmittedDirty: this.lastEmittedDirty,
1590
+ currentDirtyState: this.computeCurrentDirtyState(),
1537
1591
  },
1538
1592
  });
1539
1593
  });
@@ -1757,9 +1811,6 @@ export class ContainerRuntime
1757
1811
  // verifyNotClosed is called in FluidDataStoreContext, which is *the* expected caller.
1758
1812
  const envelope1 = content as IEnvelope;
1759
1813
  const envelope2 = createNewSignalEnvelope(envelope1.address, type, envelope1.contents);
1760
- if (targetClientId === undefined) {
1761
- this.signalTelemetryManager.applyTrackingToBroadcastSignalEnvelope(envelope2);
1762
- }
1763
1814
  this.submitSignalFn(envelope2, targetClientId);
1764
1815
  };
1765
1816
 
@@ -1897,9 +1948,9 @@ export class ContainerRuntime
1897
1948
  this.closeSummarizerDelayMs =
1898
1949
  closeSummarizerDelayOverride ?? defaultCloseSummarizerDelayMs;
1899
1950
 
1900
- this.dirtyContainer =
1901
- this.attachState !== AttachState.Attached || this.hasPendingMessages();
1902
- context.updateDirtyContainerState(this.dirtyContainer);
1951
+ // We haven't emitted dirty/saved yet, but this is the baseline so we know to emit when it changes
1952
+ this.lastEmittedDirty = this.computeCurrentDirtyState();
1953
+ context.updateDirtyContainerState(this.lastEmittedDirty);
1903
1954
 
1904
1955
  if (!this.skipSafetyFlushDuringProcessStack) {
1905
1956
  // Reference Sequence Number may have just changed, and it must be consistent across a batch,
@@ -2528,17 +2579,12 @@ export class ContainerRuntime
2528
2579
  return;
2529
2580
  }
2530
2581
 
2531
- // We need to temporary clear the dirty flags and disable
2532
- // dirty state change events to detect whether replaying ops
2533
- // has any effect.
2534
-
2535
- // Save the old state, reset to false, disable event emit
2536
- const oldState = this.dirtyContainer;
2537
- this.dirtyContainer = false;
2538
-
2582
+ // Replaying is an internal operation and we don't want to generate noise while doing it.
2583
+ // So temporarily disable dirty state change events, and save the old state.
2584
+ // When we're done, we'll emit the event if the state changed.
2585
+ const oldState = this.lastEmittedDirty;
2539
2586
  assert(this.emitDirtyDocumentEvent, 0x127 /* "dirty document event not set on replay" */);
2540
2587
  this.emitDirtyDocumentEvent = false;
2541
- let newState: boolean;
2542
2588
 
2543
2589
  try {
2544
2590
  // Any ID Allocation ops that failed to submit after the pending state was queued need to have
@@ -2546,18 +2592,18 @@ export class ContainerRuntime
2546
2592
  // Since we don't submit ID Allocation ops when staged, any outstanding ranges would be from
2547
2593
  // before staging mode so we can simply say staged: false.
2548
2594
  this.submitIdAllocationOpIfNeeded({ resubmitOutstandingRanges: true, staged: false });
2595
+ this.scheduleFlush();
2549
2596
 
2550
2597
  // replay the ops
2551
2598
  this.pendingStateManager.replayPendingStates();
2552
2599
  } finally {
2553
- // Save the new start and restore the old state, re-enable event emit
2554
- newState = this.dirtyContainer;
2555
- this.dirtyContainer = oldState;
2600
+ // Restore the old state, re-enable event emit
2601
+ this.lastEmittedDirty = oldState;
2556
2602
  this.emitDirtyDocumentEvent = true;
2557
2603
  }
2558
2604
 
2559
- // Officially transition from the old state to the new state.
2560
- this.updateDocumentDirtyState(newState);
2605
+ // This will emit an event if the state changed relative to before replay
2606
+ this.updateDocumentDirtyState();
2561
2607
  }
2562
2608
 
2563
2609
  /**
@@ -2928,6 +2974,9 @@ export class ContainerRuntime
2928
2974
  runtimeBatch: boolean,
2929
2975
  groupedBatch: boolean,
2930
2976
  ): void {
2977
+ // This message could have been the last pending local (dirtyable) message, in which case we need to update dirty state to "saved"
2978
+ this.updateDocumentDirtyState();
2979
+
2931
2980
  if (locationInBatch.batchStart) {
2932
2981
  const firstMessage = messagesWithMetadata[0]?.message;
2933
2982
  assert(firstMessage !== undefined, 0xa31 /* Batch must have at least one message */);
@@ -3043,12 +3092,6 @@ export class ContainerRuntime
3043
3092
 
3044
3093
  this._processedClientSequenceNumber = message.clientSequenceNumber;
3045
3094
 
3046
- // If there are no more pending messages after processing a local message,
3047
- // the document is no longer dirty.
3048
- if (!this.hasPendingMessages()) {
3049
- this.updateDocumentDirtyState(false);
3050
- }
3051
-
3052
3095
  // The DeltaManager used to do this, but doesn't anymore as of Loader v2.4
3053
3096
  // Anyone listening to our "op" event would expect the contents to be parsed per this same logic
3054
3097
  if (
@@ -3079,12 +3122,6 @@ export class ContainerRuntime
3079
3122
  local: boolean,
3080
3123
  savedOp?: boolean,
3081
3124
  ): void {
3082
- // If there are no more pending messages after processing a local message,
3083
- // the document is no longer dirty.
3084
- if (!this.hasPendingMessages()) {
3085
- this.updateDocumentDirtyState(false);
3086
- }
3087
-
3088
3125
  // Get the contents without the localOpMetadata because not all message types know about localOpMetadata.
3089
3126
  const contents = messagesContent.map((c) => c.contents);
3090
3127
 
@@ -3171,9 +3208,15 @@ export class ContainerRuntime
3171
3208
  }
3172
3209
  }
3173
3210
 
3174
- public processSignal(message: ISignalMessage, local: boolean): void {
3175
- const envelope = message.content as ISignalEnvelope;
3176
- const transformed: IInboundSignalMessage = {
3211
+ public processSignal(
3212
+ message: ISignalMessage<{
3213
+ type: string;
3214
+ content: ISignalEnvelope<{ type: string; content: JsonDeserialized<unknown> }>;
3215
+ }>,
3216
+ local: boolean,
3217
+ ): void {
3218
+ const envelope = message.content;
3219
+ const transformed = {
3177
3220
  clientId: message.clientId,
3178
3221
  content: envelope.contents.content,
3179
3222
  type: envelope.contents.type,
@@ -3189,22 +3232,53 @@ export class ContainerRuntime
3189
3232
  );
3190
3233
  }
3191
3234
 
3192
- if (envelope.address === undefined) {
3235
+ const fullAddress = envelope.address;
3236
+ if (fullAddress === undefined) {
3193
3237
  // No address indicates a container signal message.
3194
3238
  this.emit("signal", transformed, local);
3195
3239
  return;
3196
3240
  }
3197
3241
 
3198
- // Due to a mismatch between different layers in terms of
3199
- // what is the interface of passing signals, we need to adjust
3200
- // the signal envelope before sending it to the datastores to be processed
3201
- const envelope2: IEnvelope = {
3202
- address: envelope.address,
3203
- contents: transformed.content,
3204
- };
3205
- transformed.content = envelope2;
3242
+ this.routeNonContainerSignal(fullAddress, transformed, local);
3243
+ }
3244
+
3245
+ private routeNonContainerSignal(
3246
+ address: string,
3247
+ signalMessage: IInboundSignalMessage<{ type: string; content: JsonDeserialized<unknown> }>,
3248
+ local: boolean,
3249
+ ): void {
3250
+ // channelCollection signals are identified by no starting `/` in address.
3251
+ if (!address.startsWith("/")) {
3252
+ // Due to a mismatch between different layers in terms of
3253
+ // what is the interface of passing signals, we need to adjust
3254
+ // the signal envelope before sending it to the datastores to be processed
3255
+ const envelope = {
3256
+ address,
3257
+ contents: signalMessage.content,
3258
+ };
3259
+ signalMessage.content = envelope;
3260
+
3261
+ this.channelCollection.processSignal(signalMessage, local);
3262
+ return;
3263
+ }
3206
3264
 
3207
- this.channelCollection.processSignal(transformed, local);
3265
+ const addresses = address.split("/");
3266
+ if (addresses.length > 2 && addresses[1] === "ext") {
3267
+ const id = addresses[2] as ContainerExtensionId;
3268
+ const entry = this.extensions.get(id);
3269
+ if (entry !== undefined) {
3270
+ entry.extension.processSignal?.(addresses.slice(3), signalMessage, local);
3271
+ return;
3272
+ }
3273
+ }
3274
+
3275
+ assert(!local, 0xba0 /* No recipient found for local signal */);
3276
+ this.mc.logger.sendTelemetryEvent({
3277
+ eventName: "SignalAddressNotFound",
3278
+ ...tagCodeArtifacts({
3279
+ address,
3280
+ }),
3281
+ });
3208
3282
  }
3209
3283
 
3210
3284
  /**
@@ -3215,6 +3289,8 @@ export class ContainerRuntime
3215
3289
  * @param resubmitInfo - If defined, indicates this is a resubmission of a batch with the given Batch info needed for resubmit.
3216
3290
  */
3217
3291
  private flush(resubmitInfo?: BatchResubmitInfo): void {
3292
+ this.flushScheduled = false;
3293
+
3218
3294
  try {
3219
3295
  assert(
3220
3296
  !this.batchRunner.running,
@@ -3239,7 +3315,6 @@ export class ContainerRuntime
3239
3315
  */
3240
3316
  public orderSequentially<T>(callback: () => T): T {
3241
3317
  let checkpoint: IBatchCheckpoint | undefined;
3242
- const checkpointDirtyState = this.dirtyContainer;
3243
3318
  // eslint-disable-next-line import/no-deprecated
3244
3319
  let stageControls: StageControlsExperimental | undefined;
3245
3320
  if (this.mc.config.getBoolean("Fluid.ContainerRuntime.EnableRollback")) {
@@ -3261,10 +3336,7 @@ export class ContainerRuntime
3261
3336
  checkpoint.rollback((message: LocalBatchMessage) =>
3262
3337
  this.rollback(message.runtimeOp, message.localOpMetadata),
3263
3338
  );
3264
- // reset the dirty state after rollback to what it was before to keep it consistent
3265
- if (this.dirtyContainer !== checkpointDirtyState) {
3266
- this.updateDocumentDirtyState(checkpointDirtyState);
3267
- }
3339
+ this.updateDocumentDirtyState();
3268
3340
  stageControls?.discardChanges();
3269
3341
  stageControls = undefined;
3270
3342
  } catch (error_) {
@@ -3360,9 +3432,7 @@ export class ContainerRuntime
3360
3432
  );
3361
3433
  this.rollback(runtimeOp, localOpMetadata);
3362
3434
  });
3363
- if (this.attachState === AttachState.Attached) {
3364
- this.updateDocumentDirtyState(this.pendingMessagesCount !== 0);
3365
- }
3435
+ this.updateDocumentDirtyState();
3366
3436
  }),
3367
3437
  commitChanges: (optionsParam) => {
3368
3438
  const options = { ...defaultStagingCommitOptions, ...optionsParam };
@@ -3452,13 +3522,6 @@ export class ContainerRuntime
3452
3522
  );
3453
3523
  }
3454
3524
 
3455
- /**
3456
- * Typically ops are batched and later flushed together, but in some cases we want to flush immediately.
3457
- */
3458
- private currentlyBatching(): boolean {
3459
- return this.flushMode !== FlushMode.Immediate || this.batchRunner.running;
3460
- }
3461
-
3462
3525
  private readonly _quorum: IQuorumClients;
3463
3526
  public getQuorum(): IQuorumClients {
3464
3527
  return this._quorum;
@@ -3474,40 +3537,20 @@ export class ContainerRuntime
3474
3537
  * either were not sent out to delta stream or were not yet acknowledged.
3475
3538
  */
3476
3539
  public get isDirty(): boolean {
3477
- return this.dirtyContainer;
3540
+ // Rather than recomputing the dirty state in this moment,
3541
+ // just regurgitate the last emitted dirty state.
3542
+ return this.lastEmittedDirty;
3478
3543
  }
3479
3544
 
3480
- private isContainerMessageDirtyable({
3481
- type,
3482
- contents,
3483
- }: LocalContainerRuntimeMessage): boolean {
3484
- // Certain container runtime messages should not mark the container dirty such as the old built-in
3485
- // AgentScheduler and Garbage collector messages.
3486
- switch (type) {
3487
- case ContainerMessageType.Attach: {
3488
- const attachMessage = contents as InboundAttachMessage;
3489
- if (attachMessage.id === agentSchedulerId) {
3490
- return false;
3491
- }
3492
- break;
3493
- }
3494
- case ContainerMessageType.FluidDataStoreOp: {
3495
- const envelope = contents;
3496
- if (envelope.address === agentSchedulerId) {
3497
- return false;
3498
- }
3499
- break;
3500
- }
3501
- case ContainerMessageType.IdAllocation:
3502
- case ContainerMessageType.DocumentSchemaChange:
3503
- case ContainerMessageType.GC: {
3504
- return false;
3505
- }
3506
- default: {
3507
- break;
3508
- }
3509
- }
3510
- return true;
3545
+ /**
3546
+ * Returns true if the container is dirty: not attached, or no pending user messages (could be some "non-dirtyable" ones though)
3547
+ */
3548
+ private computeCurrentDirtyState(): boolean {
3549
+ return (
3550
+ this.attachState !== AttachState.Attached ||
3551
+ this.pendingStateManager.hasPendingUserChanges() ||
3552
+ this.outbox.containsUserChanges()
3553
+ );
3511
3554
  }
3512
3555
 
3513
3556
  /**
@@ -3525,9 +3568,6 @@ export class ContainerRuntime
3525
3568
  public submitSignal(type: string, content: unknown, targetClientId?: string): void {
3526
3569
  this.verifyNotClosed();
3527
3570
  const envelope = createNewSignalEnvelope(undefined /* address */, type, content);
3528
- if (targetClientId === undefined) {
3529
- this.signalTelemetryManager.applyTrackingToBroadcastSignalEnvelope(envelope);
3530
- }
3531
3571
  this.submitSignalFn(envelope, targetClientId);
3532
3572
  }
3533
3573
 
@@ -3545,9 +3585,7 @@ export class ContainerRuntime
3545
3585
  this.emit("attached");
3546
3586
  }
3547
3587
 
3548
- if (attachState === AttachState.Attached && !this.hasPendingMessages()) {
3549
- this.updateDocumentDirtyState(false);
3550
- }
3588
+ this.updateDocumentDirtyState();
3551
3589
  this.channelCollection.setAttachState(attachState);
3552
3590
  }
3553
3591
 
@@ -4333,22 +4371,22 @@ export class ContainerRuntime
4333
4371
  return this.pendingMessagesCount !== 0;
4334
4372
  }
4335
4373
 
4336
- private updateDocumentDirtyState(dirty: boolean): void {
4337
- if (this.attachState === AttachState.Attached) {
4338
- // Other way is not true = see this.isContainerMessageDirtyable()
4339
- assert(
4340
- !dirty || this.hasPendingMessages(),
4341
- 0x3d3 /* if doc is dirty, there has to be pending ops */,
4342
- );
4343
- } else {
4344
- assert(dirty, 0x3d2 /* Non-attached container is dirty */);
4345
- }
4374
+ /**
4375
+ * Emit "dirty" or "saved" event based on the current dirty state of the document.
4376
+ * This must be called every time the states underlying the dirty state change.
4377
+ *
4378
+ * @privateRemarks - It's helpful to think of this as an event handler registered
4379
+ * for hypothetical "changed" events for PendingStateManager, Outbox, and Container Attach machinery.
4380
+ * But those events don't exist so we manually call this wherever we know those changes happen.
4381
+ */
4382
+ private updateDocumentDirtyState(): void {
4383
+ const dirty: boolean = this.computeCurrentDirtyState();
4346
4384
 
4347
- if (this.dirtyContainer === dirty) {
4385
+ if (this.lastEmittedDirty === dirty) {
4348
4386
  return;
4349
4387
  }
4350
4388
 
4351
- this.dirtyContainer = dirty;
4389
+ this.lastEmittedDirty = dirty;
4352
4390
  if (this.emitDirtyDocumentEvent) {
4353
4391
  this.emit(dirty ? "dirty" : "saved");
4354
4392
  }
@@ -4486,13 +4524,7 @@ export class ContainerRuntime
4486
4524
  this.outbox.submit(message);
4487
4525
  }
4488
4526
 
4489
- // Note: Technically, the system "always" batches - if this case is true we'll just have a single-message batch.
4490
- const flushImmediatelyOnSubmit = !this.currentlyBatching();
4491
- if (flushImmediatelyOnSubmit) {
4492
- this.flush();
4493
- } else {
4494
- this.scheduleFlush();
4495
- }
4527
+ this.scheduleFlush();
4496
4528
  } catch (error) {
4497
4529
  const dpe = DataProcessingError.wrapIfUnrecognized(error, "ContainerRuntime.submit", {
4498
4530
  referenceSequenceNumber: this.deltaManager.lastSequenceNumber,
@@ -4501,31 +4533,28 @@ export class ContainerRuntime
4501
4533
  throw dpe;
4502
4534
  }
4503
4535
 
4504
- if (this.isContainerMessageDirtyable(containerRuntimeMessage)) {
4505
- this.updateDocumentDirtyState(true);
4506
- }
4536
+ this.updateDocumentDirtyState();
4507
4537
  }
4508
4538
 
4509
4539
  private scheduleFlush(): void {
4510
- if (this.flushTaskExists) {
4540
+ if (this.flushScheduled) {
4511
4541
  return;
4512
4542
  }
4513
-
4514
- this.flushTaskExists = true;
4515
-
4516
- // TODO: hoist this out of the function scope to save unnecessary allocations
4517
- // eslint-disable-next-line unicorn/consistent-function-scoping -- Separate `flush` method already exists in outer scope
4518
- const flush = (): void => {
4519
- this.flushTaskExists = false;
4520
- this.flush();
4521
- };
4543
+ this.flushScheduled = true;
4522
4544
 
4523
4545
  switch (this.flushMode) {
4546
+ case FlushMode.Immediate: {
4547
+ // When in Immediate flush mode, flush immediately unless we are intentionally batching multiple ops (e.g. via orderSequentially)
4548
+ if (!this.batchRunner.running) {
4549
+ this.flush();
4550
+ }
4551
+ break;
4552
+ }
4524
4553
  case FlushMode.TurnBased: {
4525
4554
  // When in TurnBased flush mode the runtime will buffer operations in the current turn and send them as a single
4526
4555
  // batch at the end of the turn
4527
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
4528
- Promise.resolve().then(flush);
4556
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises -- Container will close if flush throws
4557
+ Promise.resolve().then(() => this.flush());
4529
4558
  break;
4530
4559
  }
4531
4560
 
@@ -4534,16 +4563,12 @@ export class ContainerRuntime
4534
4563
  // When in Async flush mode, the runtime will accumulate all operations across JS turns and send them as a single
4535
4564
  // batch when all micro-tasks are complete.
4536
4565
  // Compared to TurnBased, this flush mode will capture more ops into the same batch.
4537
- setTimeout(flush, 0);
4566
+ setTimeout(() => this.flush(), 0);
4538
4567
  break;
4539
4568
  }
4540
4569
 
4541
4570
  default: {
4542
- assert(
4543
- this.batchRunner.running,
4544
- 0x587 /* Unreachable unless manually accumulating a batch */,
4545
- );
4546
- break;
4571
+ fail(0x587 /* Unreachable unless manually accumulating a batch */);
4547
4572
  }
4548
4573
  }
4549
4574
  }
@@ -4921,6 +4946,59 @@ export class ContainerRuntime
4921
4946
  }
4922
4947
  }
4923
4948
 
4949
+ // While internal, ContainerRuntime has not been converted to use the new events support.
4950
+ // Recreate the required events (new pattern) with injected, wrapper new emitter.
4951
+ // It is lazily create to avoid listeners (old events) that ultimately go nowhere.
4952
+ private readonly lazyEventsForExtensions = new Lazy<Listenable<ExtensionHostEvents>>(() => {
4953
+ const eventEmitter = createEmitter<ExtensionHostEvents>();
4954
+ this.on("connected", (clientId) => eventEmitter.emit("connected", clientId));
4955
+ this.on("disconnected", () => eventEmitter.emit("disconnected"));
4956
+ return eventEmitter;
4957
+ });
4958
+
4959
+ private readonly submitExtensionSignal: <TMessage extends TypedMessage>(
4960
+ id: string,
4961
+ addressChain: string[],
4962
+ message: OutboundExtensionMessage<TMessage>,
4963
+ ) => void;
4964
+
4965
+ public acquireExtension<
4966
+ T,
4967
+ TRuntimeProperties extends ExtensionRuntimeProperties,
4968
+ TUseContext extends unknown[],
4969
+ >(
4970
+ id: ContainerExtensionId,
4971
+ factory: ContainerExtensionFactory<T, TRuntimeProperties, TUseContext>,
4972
+ ...useContext: TUseContext
4973
+ ): T {
4974
+ let entry = this.extensions.get(id);
4975
+ if (entry === undefined) {
4976
+ const runtime = {
4977
+ isConnected: () => this.connected,
4978
+ getClientId: () => this.clientId,
4979
+ events: this.lazyEventsForExtensions.value,
4980
+ logger: this.baseLogger,
4981
+ submitAddressedSignal: (
4982
+ addressChain: string[],
4983
+ message: OutboundExtensionMessage<TRuntimeProperties["SignalMessages"]>,
4984
+ ) => {
4985
+ this.submitExtensionSignal(id, addressChain, message);
4986
+ },
4987
+ getQuorum: this.getQuorum.bind(this),
4988
+ getAudience: this.getAudience.bind(this),
4989
+ } satisfies ExtensionHost<TRuntimeProperties>;
4990
+ entry = new factory(runtime, ...useContext);
4991
+ this.extensions.set(id, entry);
4992
+ } else {
4993
+ assert(
4994
+ entry instanceof factory,
4995
+ 0xba1 /* Extension entry is not of the expected type */,
4996
+ );
4997
+ entry.extension.onNewUse(...useContext);
4998
+ }
4999
+ return entry.interface as T;
5000
+ }
5001
+
4924
5002
  private get groupedBatchingEnabled(): boolean {
4925
5003
  return this.sessionSchema.opGroupingEnabled === true;
4926
5004
  }
@@ -4930,11 +5008,44 @@ export function createNewSignalEnvelope(
4930
5008
  address: string | undefined,
4931
5009
  type: string,
4932
5010
  content: unknown,
4933
- ): Omit<ISignalEnvelope, "broadcastSignalSequenceNumber"> {
4934
- const newEnvelope: Omit<ISignalEnvelope, "broadcastSignalSequenceNumber"> = {
5011
+ ): UnsequencedSignalEnvelope {
5012
+ const newEnvelope: UnsequencedSignalEnvelope = {
4935
5013
  address,
4936
5014
  contents: { type, content },
4937
5015
  };
4938
5016
 
4939
5017
  return newEnvelope;
4940
5018
  }
5019
+
5020
+ export function isContainerMessageDirtyable({
5021
+ type,
5022
+ contents,
5023
+ }: LocalContainerRuntimeMessage): boolean {
5024
+ // Certain container runtime messages should not mark the container dirty such as the old built-in
5025
+ // AgentScheduler and Garbage collector messages.
5026
+ switch (type) {
5027
+ case ContainerMessageType.Attach: {
5028
+ const attachMessage = contents as InboundAttachMessage;
5029
+ if (attachMessage.id === agentSchedulerId) {
5030
+ return false;
5031
+ }
5032
+ break;
5033
+ }
5034
+ case ContainerMessageType.FluidDataStoreOp: {
5035
+ const envelope = contents;
5036
+ if (envelope.address === agentSchedulerId) {
5037
+ return false;
5038
+ }
5039
+ break;
5040
+ }
5041
+ case ContainerMessageType.IdAllocation:
5042
+ case ContainerMessageType.DocumentSchemaChange:
5043
+ case ContainerMessageType.GC: {
5044
+ return false;
5045
+ }
5046
+ default: {
5047
+ break;
5048
+ }
5049
+ }
5050
+ return true;
5051
+ }