@fluidframework/container-runtime 2.93.0 → 2.101.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 (117) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/container-runtime.test-files.tar +0 -0
  3. package/dist/blobManager/blobManager.d.ts +3 -0
  4. package/dist/blobManager/blobManager.d.ts.map +1 -1
  5. package/dist/blobManager/blobManager.js +3 -0
  6. package/dist/blobManager/blobManager.js.map +1 -1
  7. package/dist/blobManager/blobManagerSnapSum.d.ts +3 -0
  8. package/dist/blobManager/blobManagerSnapSum.d.ts.map +1 -1
  9. package/dist/blobManager/blobManagerSnapSum.js +12 -5
  10. package/dist/blobManager/blobManagerSnapSum.js.map +1 -1
  11. package/dist/connectionTelemetry.d.ts.map +1 -1
  12. package/dist/connectionTelemetry.js +3 -1
  13. package/dist/connectionTelemetry.js.map +1 -1
  14. package/dist/containerRuntime.d.ts +20 -3
  15. package/dist/containerRuntime.d.ts.map +1 -1
  16. package/dist/containerRuntime.js +88 -39
  17. package/dist/containerRuntime.js.map +1 -1
  18. package/dist/dataStore.d.ts +1 -1
  19. package/dist/dataStore.d.ts.map +1 -1
  20. package/dist/dataStore.js +6 -7
  21. package/dist/dataStore.js.map +1 -1
  22. package/dist/gc/garbageCollection.d.ts +4 -0
  23. package/dist/gc/garbageCollection.d.ts.map +1 -1
  24. package/dist/gc/garbageCollection.js +3 -2
  25. package/dist/gc/garbageCollection.js.map +1 -1
  26. package/dist/gc/gcDefinitions.d.ts +10 -4
  27. package/dist/gc/gcDefinitions.d.ts.map +1 -1
  28. package/dist/gc/gcDefinitions.js.map +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +16 -12
  32. package/dist/index.js.map +1 -1
  33. package/dist/metadata.d.ts +14 -0
  34. package/dist/metadata.d.ts.map +1 -1
  35. package/dist/metadata.js.map +1 -1
  36. package/dist/opLifecycle/duplicateBatchDetector.d.ts +8 -0
  37. package/dist/opLifecycle/duplicateBatchDetector.d.ts.map +1 -1
  38. package/dist/opLifecycle/duplicateBatchDetector.js +23 -1
  39. package/dist/opLifecycle/duplicateBatchDetector.js.map +1 -1
  40. package/dist/opLifecycle/opGroupingManager.js +2 -2
  41. package/dist/opLifecycle/opGroupingManager.js.map +1 -1
  42. package/dist/opLifecycle/opSplitter.d.ts.map +1 -1
  43. package/dist/opLifecycle/opSplitter.js +13 -3
  44. package/dist/opLifecycle/opSplitter.js.map +1 -1
  45. package/dist/packageVersion.d.ts +1 -1
  46. package/dist/packageVersion.d.ts.map +1 -1
  47. package/dist/packageVersion.js +1 -1
  48. package/dist/packageVersion.js.map +1 -1
  49. package/dist/runtimeLayerCompatState.d.ts +1 -1
  50. package/dist/summary/summarizerTypes.d.ts +3 -1
  51. package/dist/summary/summarizerTypes.d.ts.map +1 -1
  52. package/dist/summary/summarizerTypes.js.map +1 -1
  53. package/lib/blobManager/blobManager.d.ts +3 -0
  54. package/lib/blobManager/blobManager.d.ts.map +1 -1
  55. package/lib/blobManager/blobManager.js +3 -0
  56. package/lib/blobManager/blobManager.js.map +1 -1
  57. package/lib/blobManager/blobManagerSnapSum.d.ts +3 -0
  58. package/lib/blobManager/blobManagerSnapSum.d.ts.map +1 -1
  59. package/lib/blobManager/blobManagerSnapSum.js +12 -5
  60. package/lib/blobManager/blobManagerSnapSum.js.map +1 -1
  61. package/lib/connectionTelemetry.d.ts.map +1 -1
  62. package/lib/connectionTelemetry.js +3 -1
  63. package/lib/connectionTelemetry.js.map +1 -1
  64. package/lib/containerRuntime.d.ts +20 -3
  65. package/lib/containerRuntime.d.ts.map +1 -1
  66. package/lib/containerRuntime.js +90 -41
  67. package/lib/containerRuntime.js.map +1 -1
  68. package/lib/dataStore.d.ts +1 -1
  69. package/lib/dataStore.d.ts.map +1 -1
  70. package/lib/dataStore.js +1 -2
  71. package/lib/dataStore.js.map +1 -1
  72. package/lib/gc/garbageCollection.d.ts +4 -0
  73. package/lib/gc/garbageCollection.d.ts.map +1 -1
  74. package/lib/gc/garbageCollection.js +3 -2
  75. package/lib/gc/garbageCollection.js.map +1 -1
  76. package/lib/gc/gcDefinitions.d.ts +10 -4
  77. package/lib/gc/gcDefinitions.d.ts.map +1 -1
  78. package/lib/gc/gcDefinitions.js.map +1 -1
  79. package/lib/index.d.ts +1 -0
  80. package/lib/index.d.ts.map +1 -1
  81. package/lib/index.js +1 -0
  82. package/lib/index.js.map +1 -1
  83. package/lib/metadata.d.ts +14 -0
  84. package/lib/metadata.d.ts.map +1 -1
  85. package/lib/metadata.js.map +1 -1
  86. package/lib/opLifecycle/duplicateBatchDetector.d.ts +8 -0
  87. package/lib/opLifecycle/duplicateBatchDetector.d.ts.map +1 -1
  88. package/lib/opLifecycle/duplicateBatchDetector.js +23 -1
  89. package/lib/opLifecycle/duplicateBatchDetector.js.map +1 -1
  90. package/lib/opLifecycle/opGroupingManager.js +2 -2
  91. package/lib/opLifecycle/opGroupingManager.js.map +1 -1
  92. package/lib/opLifecycle/opSplitter.d.ts.map +1 -1
  93. package/lib/opLifecycle/opSplitter.js +13 -3
  94. package/lib/opLifecycle/opSplitter.js.map +1 -1
  95. package/lib/packageVersion.d.ts +1 -1
  96. package/lib/packageVersion.d.ts.map +1 -1
  97. package/lib/packageVersion.js +1 -1
  98. package/lib/packageVersion.js.map +1 -1
  99. package/lib/runtimeLayerCompatState.d.ts +1 -1
  100. package/lib/summary/summarizerTypes.d.ts +3 -1
  101. package/lib/summary/summarizerTypes.d.ts.map +1 -1
  102. package/lib/summary/summarizerTypes.js.map +1 -1
  103. package/package.json +20 -20
  104. package/src/blobManager/blobManager.ts +3 -0
  105. package/src/blobManager/blobManagerSnapSum.ts +12 -5
  106. package/src/connectionTelemetry.ts +15 -10
  107. package/src/containerRuntime.ts +110 -47
  108. package/src/dataStore.ts +5 -6
  109. package/src/gc/garbageCollection.ts +8 -6
  110. package/src/gc/gcDefinitions.ts +11 -4
  111. package/src/index.ts +5 -0
  112. package/src/metadata.ts +14 -0
  113. package/src/opLifecycle/duplicateBatchDetector.ts +27 -6
  114. package/src/opLifecycle/opGroupingManager.ts +2 -2
  115. package/src/opLifecycle/opSplitter.ts +13 -3
  116. package/src/packageVersion.ts +1 -1
  117. package/src/summary/summarizerTypes.ts +3 -1
@@ -57,6 +57,7 @@ import type {
57
57
  ITelemetryBaseLogger,
58
58
  Listenable,
59
59
  } from "@fluidframework/core-interfaces";
60
+ import { LogLevel } from "@fluidframework/core-interfaces";
60
61
  import type {
61
62
  IFluidHandleContext,
62
63
  IFluidHandleInternal,
@@ -95,7 +96,6 @@ import { FetchSource, MessageType } from "@fluidframework/driver-definitions/int
95
96
  import { readAndParse } from "@fluidframework/driver-utils/internal";
96
97
  import type { IIdCompressor } from "@fluidframework/id-compressor";
97
98
  import type {
98
- // eslint-disable-next-line import-x/no-deprecated -- Will be undeprecated in 2.100.0 when it becomes an internal API
99
99
  IIdCompressorCore,
100
100
  IdCreationRange,
101
101
  SerializedIdCompressorWithNoSession,
@@ -105,6 +105,7 @@ import {
105
105
  createIdCompressor,
106
106
  createSessionId,
107
107
  deserializeIdCompressor,
108
+ toIdCompressorWithCore,
108
109
  } from "@fluidframework/id-compressor/internal";
109
110
  import {
110
111
  FlushMode,
@@ -131,7 +132,6 @@ import type {
131
132
  IContainerRuntimeBaseInternal,
132
133
  MinimumVersionForCollab,
133
134
  ContainerExtensionExpectations,
134
- ContainerRuntimeBaseAlpha,
135
135
  } from "@fluidframework/runtime-definitions/internal";
136
136
  import {
137
137
  addBlobToSummary,
@@ -173,6 +173,7 @@ import {
173
173
  wrapError,
174
174
  tagCodeArtifacts,
175
175
  normalizeError,
176
+ toITelemetryLoggerExt,
176
177
  } from "@fluidframework/telemetry-utils/internal";
177
178
  import { gt } from "semver-ts";
178
179
  import { v4 as uuid } from "uuid";
@@ -675,18 +676,11 @@ export function getDeviceSpec(): {
675
676
  deviceMemory?: number | undefined;
676
677
  hardwareConcurrency?: number | undefined;
677
678
  } {
678
- try {
679
- if (typeof navigator === "object" && navigator !== null) {
680
- return {
681
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
682
- deviceMemory: (navigator as any).deviceMemory,
683
- hardwareConcurrency: navigator.hardwareConcurrency,
684
- };
685
- }
686
- } catch {
687
- // Eat the error
688
- }
689
- return {};
679
+ return {
680
+ // deviceMemory is only available in browsers and is not part of the Navigator type definition. In Node 22 it is undefined.
681
+ deviceMemory: (navigator as Navigator & { deviceMemory?: number }).deviceMemory,
682
+ hardwareConcurrency: navigator.hardwareConcurrency,
683
+ };
690
684
  }
691
685
 
692
686
  /**
@@ -846,7 +840,7 @@ export async function loadContainerRuntime(
846
840
  * @legacy @alpha
847
841
  */
848
842
  export async function loadContainerRuntimeAlpha(params: LoadContainerRuntimeParams): Promise<{
849
- runtime: IContainerRuntime & ContainerRuntimeBaseAlpha & IRuntime;
843
+ runtime: IContainerRuntime & IRuntime;
850
844
  }> {
851
845
  return ContainerRuntime.loadRuntime2({
852
846
  ...params,
@@ -1185,7 +1179,6 @@ export class ContainerRuntime
1185
1179
  idCompressorMode = desiredIdCompressorMode;
1186
1180
  }
1187
1181
 
1188
- // eslint-disable-next-line import-x/no-deprecated -- Will be undeprecated in 2.100.0 when it becomes an internal API
1189
1182
  const createIdCompressorFn = (): IIdCompressor & IIdCompressorCore => {
1190
1183
  /**
1191
1184
  * Because the IdCompressor emits so much telemetry, this function is used to sample
@@ -1204,17 +1197,21 @@ export class ContainerRuntime
1204
1197
  const pendingLocalState = context.pendingLocalState as IPendingRuntimeState;
1205
1198
 
1206
1199
  if (pendingLocalState?.pendingIdCompressorState !== undefined) {
1207
- return deserializeIdCompressor(
1208
- pendingLocalState.pendingIdCompressorState,
1209
- compressorLogger,
1200
+ return toIdCompressorWithCore(
1201
+ deserializeIdCompressor(
1202
+ pendingLocalState.pendingIdCompressorState,
1203
+ toITelemetryLoggerExt(compressorLogger),
1204
+ ),
1210
1205
  );
1211
1206
  } else if (serializedIdCompressor === undefined) {
1212
- return createIdCompressor(compressorLogger);
1207
+ return toIdCompressorWithCore(createIdCompressor(compressorLogger));
1213
1208
  } else {
1214
- return deserializeIdCompressor(
1215
- serializedIdCompressor,
1216
- createSessionId(),
1217
- compressorLogger,
1209
+ return toIdCompressorWithCore(
1210
+ deserializeIdCompressor(
1211
+ serializedIdCompressor,
1212
+ createSessionId(),
1213
+ toITelemetryLoggerExt(compressorLogger),
1214
+ ),
1218
1215
  );
1219
1216
  }
1220
1217
  };
@@ -1339,7 +1336,12 @@ export class ContainerRuntime
1339
1336
  targetClientId?: string,
1340
1337
  ) => void;
1341
1338
  public readonly disposeFn: (error?: ICriticalContainerError) => void;
1342
- public readonly closeFn: (error?: ICriticalContainerError) => void;
1339
+
1340
+ /**
1341
+ * Initiate closing of the container due to a critical error.
1342
+ * @param error - The critical error that caused the container to close.
1343
+ */
1344
+ private readonly closeFn: (error: ICriticalContainerError) => void;
1343
1345
 
1344
1346
  public get flushMode(): FlushMode {
1345
1347
  return this._flushMode;
@@ -1378,7 +1380,6 @@ export class ContainerRuntime
1378
1380
  return this.documentsSchemaController.sessionSchema.runtime;
1379
1381
  }
1380
1382
 
1381
- // eslint-disable-next-line import-x/no-deprecated -- Will be undeprecated in 2.100.0 when it becomes an internal API
1382
1383
  private _idCompressor: (IIdCompressor & IIdCompressorCore) | undefined;
1383
1384
 
1384
1385
  // We accumulate Id compressor Ops while Id compressor is not loaded yet (only for "delayed" mode)
@@ -1394,7 +1395,6 @@ export class ContainerRuntime
1394
1395
  /**
1395
1396
  * {@inheritDoc @fluidframework/runtime-definitions#IContainerRuntimeBase.idCompressor}
1396
1397
  */
1397
- // eslint-disable-next-line import-x/no-deprecated -- Will be undeprecated in 2.100.0 when it becomes an internal API
1398
1398
  public get idCompressor(): (IIdCompressor & IIdCompressorCore) | undefined {
1399
1399
  // Expose ID Compressor only if it's On from the start.
1400
1400
  // If container uses delayed mode, then we can only expose generateDocumentUniqueId() and nothing else.
@@ -1615,7 +1615,6 @@ export class ContainerRuntime
1615
1615
 
1616
1616
  blobManagerLoadInfo: IBlobManagerLoadInfo,
1617
1617
  private readonly _storage: IContainerStorageService,
1618
- // eslint-disable-next-line import-x/no-deprecated -- Will be undeprecated in 2.100.0 when it becomes an internal API
1619
1618
  private readonly createIdCompressorFn: () => IIdCompressor & IIdCompressorCore,
1620
1619
 
1621
1620
  private readonly documentsSchemaController: DocumentsSchemaController,
@@ -1891,20 +1890,38 @@ export class ContainerRuntime
1891
1890
  this.mc.config.getNumber("Fluid.ContainerRuntime.StagingModeAutoFlushThreshold") ??
1892
1891
  runtimeOptions.stagingModeAutoFlushThreshold ??
1893
1892
  defaultStagingModeAutoFlushThreshold;
1894
- this.batchIdTrackingEnabled =
1895
- this.mc.config.getBoolean("Fluid.Container.enableOfflineFull") ??
1896
- this.mc.config.getBoolean("Fluid.ContainerRuntime.enableBatchIdTracking") ??
1897
- false;
1898
-
1899
- if (this.batchIdTrackingEnabled && this._flushMode !== FlushMode.TurnBased) {
1893
+ // BatchId tracking powers DuplicateBatchDetector (catching forked-container duplicates)
1894
+ // and is also a prerequisite for the Offline Load feature. It is enabled by default
1895
+ // when both TurnBased flush mode and grouped batching are active; the kill-switch
1896
+ // below allows disabling it without a code change if a regression is observed.
1897
+ // Grouped batching is required because resubmits can produce empty batches that must
1898
+ // still be sent on the wire as a placeholder grouped batch to preserve their batchId
1899
+ // (see OpGroupingManager.createEmptyGroupedBatch / outbox.flushEmptyBatch).
1900
+ // Offline Load requires both prerequisites, so a consumer that opts into it without
1901
+ // them gets an explicit UsageError rather than silent degradation.
1902
+ const offlineLoadRequested =
1903
+ this.mc.config.getBoolean("Fluid.Container.enableOfflineFull") === true;
1904
+ const disableBatchIdTracking =
1905
+ this.mc.config.getBoolean("Fluid.ContainerRuntime.DisableBatchIdTracking") === true;
1906
+
1907
+ if (offlineLoadRequested && this._flushMode !== FlushMode.TurnBased) {
1900
1908
  const error = new UsageError("Offline mode is only supported in turn-based mode");
1901
1909
  this.closeFn(error);
1902
1910
  throw error;
1903
1911
  }
1912
+ if (offlineLoadRequested && !this.groupedBatchingEnabled) {
1913
+ const error = new UsageError("Offline mode requires grouped batching to be enabled");
1914
+ this.closeFn(error);
1915
+ throw error;
1916
+ }
1917
+
1918
+ this.batchIdTrackingEnabled =
1919
+ !disableBatchIdTracking &&
1920
+ this._flushMode === FlushMode.TurnBased &&
1921
+ this.groupedBatchingEnabled;
1904
1922
 
1905
- // DuplicateBatchDetection is only enabled if Offline Load is enabled
1906
- // It maintains a cache of all batchIds/sequenceNumbers within the collab window.
1907
- // Don't waste resources doing so if not needed.
1923
+ // DuplicateBatchDetector maintains a cache of all batchIds/sequenceNumbers within the
1924
+ // collab window. Skip allocating it when batchId tracking is off.
1908
1925
  if (this.batchIdTrackingEnabled) {
1909
1926
  this.duplicateBatchDetector = new DuplicateBatchDetector(recentBatchInfo);
1910
1927
  }
@@ -1924,6 +1941,7 @@ export class ContainerRuntime
1924
1941
 
1925
1942
  this.garbageCollector = GarbageCollector.create({
1926
1943
  runtime: this,
1944
+ closeFn: this.closeFn,
1927
1945
  gcOptions: runtimeOptions.gcOptions,
1928
1946
  baseSnapshot,
1929
1947
  baseLogger: this.mc.logger,
@@ -2140,13 +2158,6 @@ export class ContainerRuntime
2140
2158
  // (We have to call flush _before_ processing a runtime op, but after is ok for non-runtime op)
2141
2159
  this.deltaManager.on("op", () => this.flush());
2142
2160
 
2143
- // logging hardware telemetry
2144
- this.baseLogger.send({
2145
- category: "generic",
2146
- eventName: "DeviceSpec",
2147
- ...getDeviceSpec(),
2148
- });
2149
-
2150
2161
  this.mc.logger.sendTelemetryEvent({
2151
2162
  eventName: "ContainerLoadStats",
2152
2163
  ...this.createContainerMetadata,
@@ -2169,6 +2180,8 @@ export class ContainerRuntime
2169
2180
  groupedBatchingEnabled: this.groupedBatchingEnabled,
2170
2181
  initialSequenceNumber: this.deltaManager.initialSequenceNumber,
2171
2182
  minVersionForCollab: this.minVersionForCollab,
2183
+ // logging hardware telemetry
2184
+ deviceSpec: { ...getDeviceSpec() },
2172
2185
  });
2173
2186
 
2174
2187
  ReportOpPerfTelemetry(this.clientId, this._deltaManager, this, this.baseLogger);
@@ -2402,12 +2415,18 @@ export class ContainerRuntime
2402
2415
  }
2403
2416
  }
2404
2417
 
2418
+ public close(): void {
2419
+ this.garbageCollector.dispose();
2420
+ }
2421
+
2405
2422
  public dispose(error?: Error): void {
2406
2423
  if (this._disposed) {
2407
2424
  return;
2408
2425
  }
2409
2426
  this._disposed = true;
2410
2427
 
2428
+ // The ContainerRuntimeDisposed event is redundant with the loader's ContainerDispose event
2429
+ // (see #27126) and can be removed once the change for ContainerDispose has saturated in telemetry.
2411
2430
  this.mc.logger.sendTelemetryEvent(
2412
2431
  {
2413
2432
  eventName: "ContainerRuntimeDisposed",
@@ -2416,6 +2435,7 @@ export class ContainerRuntime
2416
2435
  attachState: this.attachState,
2417
2436
  },
2418
2437
  error,
2438
+ LogLevel.info,
2419
2439
  );
2420
2440
 
2421
2441
  if (this.summaryManager !== undefined) {
@@ -3598,7 +3618,18 @@ export class ContainerRuntime
3598
3618
  let stageControls: StageControlsInternal | undefined;
3599
3619
  if (this.mc.config.getBoolean("Fluid.ContainerRuntime.EnableRollback") === true) {
3600
3620
  if (!this.batchRunner.running && !this.inStagingMode) {
3601
- stageControls = this.enterStagingMode();
3621
+ // Use silent=true to suppress stagingModeChanged events for orderSequentially.
3622
+ // orderSequentially uses staging mode as a rollback mechanism.
3623
+ // Emitting stagingModeChanged here would:
3624
+ // - Cause UI flicker — consumers rendering staging mode event would see
3625
+ // unexpected enter/exit flashes on every orderSequentially call.
3626
+ // - consumers cannot distinguish this internal usage
3627
+ // from a user explicitly entering staging mode, as there is no source field
3628
+ // on the event to filter by.
3629
+ // - if orderSequentially is
3630
+ // later reimplemented without staging mode, consumers calibrated
3631
+ // to these events would break silently.
3632
+ stageControls = this.enterStagingModeCore(true);
3602
3633
  }
3603
3634
  // Note: we are not touching any batches other than mainBatch here, for two reasons:
3604
3635
  // 1. It would not help, as other batches are flushed independently from main batch.
@@ -3674,9 +3705,22 @@ export class ContainerRuntime
3674
3705
  * Enter Staging Mode, such that ops submitted to the ContainerRuntime will not be sent to the ordering service.
3675
3706
  * To exit Staging Mode, call either discardChanges or commitChanges on the Stage Controls returned from this method.
3676
3707
  *
3708
+ * @remarks
3709
+ * The `stagingModeChanged` event is emitted when staging mode is entered or exited via this method.
3710
+ * It is NOT emitted when staging mode is used internally (e.g. by `orderSequentially` for rollback support).
3711
+ *
3677
3712
  * @returns Controls for exiting Staging Mode.
3678
3713
  */
3679
- public enterStagingMode = (): StageControlsInternal => {
3714
+ public enterStagingMode = (): StageControlsInternal => this.enterStagingModeCore(false);
3715
+
3716
+ /**
3717
+ * Internal implementation of enterStagingMode.
3718
+ * @param silent - When true, suppresses `stagingModeChanged` event emission.
3719
+ * Pass `true` when staging mode is used as an internal implementation detail (e.g. by
3720
+ * `orderSequentially` for rollback support) so that external listeners only observe
3721
+ * user-initiated staging mode transitions. Pass `false` for all public entry points.
3722
+ */
3723
+ private readonly enterStagingModeCore = (silent: boolean): StageControlsInternal => {
3680
3724
  if (this.stageControls !== undefined) {
3681
3725
  throw new UsageError("Already in staging mode");
3682
3726
  }
@@ -3688,10 +3732,15 @@ export class ContainerRuntime
3688
3732
  // since we mark whole batches as "staged" or not to indicate whether to submit them.
3689
3733
  this.flush();
3690
3734
 
3735
+ // Note: `silent` is captured from the enclosing `enterStagingModeCore` call.
3736
+ // When `true`, both enter and exit events are suppressed (see orderSequentially).
3691
3737
  const exitStagingMode = (
3692
3738
  discardOrCommit: () => IPendingMessage["batchInfo"][],
3693
3739
  exitMethod: "commit" | "discard",
3694
3740
  ): void => {
3741
+ if (this.stageControls !== stageControls) {
3742
+ throw new UsageError("Not in staging mode");
3743
+ }
3695
3744
  try {
3696
3745
  PerformanceEvent.timedExec(
3697
3746
  this.mc.logger,
@@ -3723,6 +3772,12 @@ export class ContainerRuntime
3723
3772
  this.closeFn(normalizedError);
3724
3773
  throw normalizedError;
3725
3774
  }
3775
+ if (!silent) {
3776
+ this.emit("stagingModeChanged", {
3777
+ inStagingMode: false,
3778
+ commit: exitMethod === "commit",
3779
+ });
3780
+ }
3726
3781
  };
3727
3782
 
3728
3783
  const stageControls: StageControlsInternal = {
@@ -3752,8 +3807,16 @@ export class ContainerRuntime
3752
3807
 
3753
3808
  this.stageControls = stageControls;
3754
3809
  this.channelCollection.notifyStagingMode(true);
3810
+ if (!silent) {
3811
+ try {
3812
+ this.emit("stagingModeChanged", { inStagingMode: true });
3813
+ } catch (error) {
3814
+ // Don't let a listener error prevent the caller from receiving stage controls.
3815
+ this.mc.logger.sendErrorEvent({ eventName: "StagingModeChangedError" }, error);
3816
+ }
3817
+ }
3755
3818
 
3756
- return this.stageControls;
3819
+ return stageControls;
3757
3820
  };
3758
3821
 
3759
3822
  /**
package/src/dataStore.ts CHANGED
@@ -7,11 +7,10 @@ import { AttachState } from "@fluidframework/container-definitions";
7
7
  import type { FluidObject } from "@fluidframework/core-interfaces";
8
8
  import type { IFluidHandleInternal } from "@fluidframework/core-interfaces/internal";
9
9
  import { assert, unreachableCase } from "@fluidframework/core-utils/internal";
10
- import {
11
- type AliasResult,
12
- type IDataStore,
13
- type IFluidDataStoreChannel,
14
- asLegacyAlpha,
10
+ import type {
11
+ AliasResult,
12
+ IDataStore,
13
+ IFluidDataStoreChannel,
15
14
  } from "@fluidframework/runtime-definitions/internal";
16
15
  import {
17
16
  type ITelemetryLoggerExt,
@@ -81,7 +80,7 @@ class DataStore implements IDataStore {
81
80
  if (alias.includes("/")) {
82
81
  throw new UsageError(`The alias cannot contain slashes: '${alias}'`);
83
82
  }
84
- if (asLegacyAlpha(this.parentContext.containerRuntime).inStagingMode === true) {
83
+ if (this.parentContext.containerRuntime.inStagingMode === true) {
85
84
  throw new UsageError("Cannot set aliases while in staging mode");
86
85
  }
87
86
 
@@ -3,6 +3,7 @@
3
3
  * Licensed under the MIT License.
4
4
  */
5
5
 
6
+ import type { ICriticalContainerError } from "@fluidframework/container-definitions";
6
7
  import type { IRequest } from "@fluidframework/core-interfaces";
7
8
  import { assert, LazyPromise, Timer } from "@fluidframework/core-utils/internal";
8
9
  import type { ISnapshotTree } from "@fluidframework/driver-definitions/internal";
@@ -141,6 +142,10 @@ export class GarbageCollector implements IGarbageCollector {
141
142
  private completedRuns = 0;
142
143
 
143
144
  private readonly runtime: IGarbageCollectionRuntime;
145
+ /**
146
+ * Called when the runtime should close because of an error.
147
+ */
148
+ private readonly closeFn: (error: ICriticalContainerError) => void;
144
149
  private readonly isSummarizerClient: boolean;
145
150
 
146
151
  private readonly summaryStateTracker: GCSummaryStateTracker;
@@ -168,6 +173,7 @@ export class GarbageCollector implements IGarbageCollector {
168
173
 
169
174
  protected constructor(createParams: IGarbageCollectorCreateParams) {
170
175
  this.runtime = createParams.runtime;
176
+ this.closeFn = createParams.closeFn;
171
177
  this.isSummarizerClient = createParams.isSummarizerClient;
172
178
  this.getNodePackagePath = createParams.getNodePackagePath;
173
179
  this.getLastSummaryTimestampMs = createParams.getLastSummaryTimestampMs;
@@ -202,14 +208,10 @@ export class GarbageCollector implements IGarbageCollector {
202
208
  }
203
209
  timeoutMs = overrideSessionExpiryTimeoutMs ?? timeoutMs;
204
210
  if (timeoutMs <= 0) {
205
- this.runtime.closeFn(
206
- new ClientSessionExpiredError(`Client session expired.`, timeoutMs),
207
- );
211
+ this.closeFn(new ClientSessionExpiredError(`Client session expired.`, timeoutMs));
208
212
  }
209
213
  this.sessionExpiryTimer = new Timer(timeoutMs, () => {
210
- this.runtime.closeFn(
211
- new ClientSessionExpiredError(`Client session expired.`, timeoutMs),
212
- );
214
+ this.closeFn(new ClientSessionExpiredError(`Client session expired.`, timeoutMs));
213
215
  });
214
216
  this.sessionExpiryTimer.start();
215
217
  this.sessionExpiryTimerStarted = Date.now();
@@ -363,10 +363,6 @@ export interface IGarbageCollectionRuntime {
363
363
  * Returns the type of the GC node.
364
364
  */
365
365
  getNodeType(nodePath: string): GCNodeType;
366
- /**
367
- * Called when the runtime should close because of an error.
368
- */
369
- closeFn: (error?: ICriticalContainerError) => void;
370
366
  }
371
367
 
372
368
  /**
@@ -453,6 +449,12 @@ export interface IGarbageCollector {
453
449
  */
454
450
  isNodeDeleted(nodePath: string): boolean;
455
451
  setConnectionState(canSendOps: boolean, clientId?: string): void;
452
+ /**
453
+ * Cancels all GC timers and clears tracked state so timers do not keep the event loop alive
454
+ * or leak memory.
455
+ * @remarks
456
+ * This is idempotent - it is safe to call multiple times.
457
+ */
456
458
  dispose(): void;
457
459
  }
458
460
 
@@ -497,6 +499,11 @@ export interface IGCNodeUpdatedProps {
497
499
  */
498
500
  export interface IGarbageCollectorCreateParams {
499
501
  readonly runtime: IGarbageCollectionRuntime;
502
+ /**
503
+ * Initiate closing of the container due to an error.
504
+ */
505
+ readonly closeFn: (error: ICriticalContainerError) => void;
506
+
500
507
  readonly gcOptions: IGCRuntimeOptions;
501
508
  readonly baseLogger: ITelemetryLoggerExt;
502
509
  readonly existing: boolean;
package/src/index.ts CHANGED
@@ -30,6 +30,11 @@ export type {
30
30
  } from "./messageTypes.js";
31
31
  export { ContainerMessageType } from "./messageTypes.js";
32
32
  export type { IBlobManagerLoadInfo } from "./blobManager/index.js";
33
+ export {
34
+ blobManagerBasePath,
35
+ blobsTreeName,
36
+ redirectTableBlobName,
37
+ } from "./blobManager/index.js";
33
38
  export type { IDataStoreAliasMessage } from "./dataStore.js";
34
39
  export { FluidDataStoreRegistry } from "./dataStoreRegistry.js";
35
40
  export {
package/src/metadata.ts CHANGED
@@ -40,6 +40,20 @@ export interface IBatchMetadata {
40
40
  * Maybe set on first message of a batch, to the batchId generated when resubmitting (set/fixed on first resubmit)
41
41
  */
42
42
  batchId?: BatchId;
43
+ /**
44
+ * Set on the envelope of a grouped batch op to the number of inner ops it contains.
45
+ * Exposed on the wire so consumers can record batch sizes in telemetry without parsing the grouped batch contents.
46
+ *
47
+ * Observable values:
48
+ * - Absent: either this is not a grouped batch envelope (e.g. a singleton batch that bypassed grouping), OR the producing runtime predates this field. Until the rollout is complete, telemetry consumers should treat absence as ambiguous and parse the envelope contents if a precise count is required for a grouped batch.
49
+ * - `0`: empty-grouped-batch placeholder produced when a resubmitted batch becomes empty.
50
+ * - `N` (N \> 0): grouped batch with N inner ops. For a chunked grouped batch this appears only on the last chunk's envelope (intermediate chunks carry no metadata).
51
+ *
52
+ * The field is intentionally advisory-only: the runtime does not validate that an inbound value matches the batch's actual inner op count. It is consumed exclusively by off-runtime telemetry.
53
+ *
54
+ * The field is always (re)stamped at outbound time from the current batch's actual size — `groupBatch` reads `batch.messages.length` directly, `createEmptyGroupedBatch` always writes `0`, and the chunking path only ever sees freshly-grouped envelopes from the same flush. It is never propagated from stashed pending state to the wire: on resubmit, ops re-enter grouping and the count is recomputed from the (possibly squashed, dropped, or added) outbound batch. This means the wire value always reflects the actual outbound size, even when the resubmitted batch differs from the original.
55
+ */
56
+ groupedOpCount?: number;
43
57
  }
44
58
 
45
59
  /**
@@ -28,6 +28,16 @@ export class DuplicateBatchDetector {
28
28
  */
29
29
  private readonly batchIdsBySeqNum = new Map<number, string>();
30
30
 
31
+ /**
32
+ * Number of inbound batches processed since the last summary. Reset by getRecentBatchInfoForSummary.
33
+ */
34
+ private processedBatchCount = 0;
35
+
36
+ /**
37
+ * Largest tracked-batch count observed since the last summary. Reset by getRecentBatchInfoForSummary.
38
+ */
39
+ private peakTrackedBatchCount = 0;
40
+
31
41
  /**
32
42
  * Initialize from snapshot data if provided - otherwise initialize empty
33
43
  */
@@ -37,6 +47,7 @@ export class DuplicateBatchDetector {
37
47
  this.batchIdsBySeqNum.set(seqNum, batchId);
38
48
  this.seqNumByBatchId.set(batchId, seqNum);
39
49
  }
50
+ this.peakTrackedBatchCount = this.batchIdsBySeqNum.size;
40
51
  }
41
52
  }
42
53
 
@@ -50,6 +61,7 @@ export class DuplicateBatchDetector {
50
61
  batchStart: BatchStartInfo,
51
62
  ): { duplicate: true; otherSequenceNumber: number } | { duplicate: false } {
52
63
  const { sequenceNumber, minimumSequenceNumber } = batchStart.keyMessage;
64
+ this.processedBatchCount++;
53
65
 
54
66
  // Glance at this batch's MSN. Any batchIds we're tracking with a lower sequence number are now safe to forget.
55
67
  // Why? Because any other client holding the same batch locally would have seen the earlier batch and closed before submitting its duplicate.
@@ -80,6 +92,9 @@ export class DuplicateBatchDetector {
80
92
  // Add new batch
81
93
  this.batchIdsBySeqNum.set(sequenceNumber, batchId);
82
94
  this.seqNumByBatchId.set(batchId, sequenceNumber);
95
+ if (this.batchIdsBySeqNum.size > this.peakTrackedBatchCount) {
96
+ this.peakTrackedBatchCount = this.batchIdsBySeqNum.size;
97
+ }
83
98
 
84
99
  return { duplicate: false };
85
100
  }
@@ -108,16 +123,22 @@ export class DuplicateBatchDetector {
108
123
  public getRecentBatchInfoForSummary(
109
124
  telemetryContext?: ITelemetryContext,
110
125
  ): [number, string][] | undefined {
126
+ if (telemetryContext !== undefined) {
127
+ const prefix = "fluid_DuplicateBatchDetector_";
128
+ telemetryContext.set(prefix, "recentBatchCount", this.batchIdsBySeqNum.size);
129
+ telemetryContext.set(prefix, "peakRecentBatchCount", this.peakTrackedBatchCount);
130
+ telemetryContext.set(prefix, "processedBatchCount", this.processedBatchCount);
131
+ }
132
+
133
+ // Reset per-window perf counters so each summary covers only the activity since the
134
+ // previous one. Peak resets to the current size (the floor for the next window).
135
+ this.processedBatchCount = 0;
136
+ this.peakTrackedBatchCount = this.batchIdsBySeqNum.size;
137
+
111
138
  if (this.batchIdsBySeqNum.size === 0) {
112
139
  return undefined;
113
140
  }
114
141
 
115
- telemetryContext?.set(
116
- "fluid_DuplicateBatchDetector_",
117
- "recentBatchCount",
118
- this.batchIdsBySeqNum.size,
119
- );
120
-
121
142
  return [...this.batchIdsBySeqNum.entries()];
122
143
  }
123
144
  }
@@ -100,7 +100,7 @@ export class OpGroupingManager {
100
100
  const serializedOp = JSON.stringify(emptyGroupedBatch);
101
101
 
102
102
  const placeholderMessage: LocalEmptyBatchPlaceholder = {
103
- metadata: { batchId: resubmittingBatchId },
103
+ metadata: { batchId: resubmittingBatchId, groupedOpCount: 0 },
104
104
  localOpMetadata: { emptyBatch: true },
105
105
  referenceSequenceNumber,
106
106
  runtimeOp: emptyGroupedBatch,
@@ -169,7 +169,7 @@ export class OpGroupingManager {
169
169
  ...batch,
170
170
  messages: [
171
171
  {
172
- metadata: { batchId: groupedBatchId },
172
+ metadata: { batchId: groupedBatchId, groupedOpCount: batch.messages.length },
173
173
  referenceSequenceNumber: batch.messages[0].referenceSequenceNumber,
174
174
  contents: serializedContent,
175
175
  },
@@ -171,12 +171,22 @@ export class OpSplitter {
171
171
  );
172
172
  }
173
173
 
174
- // The last chunk will be part of the new batch and needs to
175
- // preserve the batch metadata of the original batch
174
+ // The last chunk will be part of the new batch and needs to preserve the
175
+ // batch metadata of the original batch. groupedOpCount is surfaced here
176
+ // (and only here, not on intermediate chunks) because intermediate chunks
177
+ // don't carry ops — they carry parts of a payload that only become ops
178
+ // once the last chunk is processed and the payload is reassembled.
179
+ // Stamping every chunk would let an observer double-count messages.
180
+ // batchId is deliberately not forwarded — it's a runtime dedup field
181
+ // consumed only after processChunk restores originalMetadata, not by
182
+ // wire observers.
176
183
  const lastChunk = chunkToBatchMessage(
177
184
  chunks[chunks.length - 1],
178
185
  batch.referenceSequenceNumber,
179
- { batch: firstMessage.metadata?.batch },
186
+ {
187
+ batch: firstMessage.metadata?.batch,
188
+ groupedOpCount: firstMessage.metadata?.groupedOpCount,
189
+ },
180
190
  );
181
191
 
182
192
  this.logger.sendPerformanceEvent({
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  export const pkgName = "@fluidframework/container-runtime";
9
- export const pkgVersion = "2.93.0";
9
+ export const pkgVersion = "2.101.0";
@@ -125,8 +125,10 @@ export interface ISummarizerRuntime extends IConnectableRuntime {
125
125
  */
126
126
  readonly summarizerClientId: string | undefined;
127
127
  readonly deltaManager: IDeltaManager<ISequencedDocumentMessage, IDocumentMessage>;
128
+ /**
129
+ * Initiate disposal of the container.
130
+ */
128
131
  disposeFn(): void;
129
- closeFn(): void;
130
132
  on(
131
133
  event: "op",
132
134
  listener: (op: ISequencedDocumentMessage, runtimeMessage?: boolean) => void,