@fluidframework/container-runtime 2.101.1 → 2.102.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 (49) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/container-runtime.test-files.tar +0 -0
  3. package/dist/containerRuntime.d.ts.map +1 -1
  4. package/dist/containerRuntime.js +48 -9
  5. package/dist/containerRuntime.js.map +1 -1
  6. package/dist/dataStoreContext.d.ts.map +1 -1
  7. package/dist/dataStoreContext.js +1 -4
  8. package/dist/dataStoreContext.js.map +1 -1
  9. package/dist/gc/garbageCollection.d.ts.map +1 -1
  10. package/dist/gc/garbageCollection.js +12 -5
  11. package/dist/gc/garbageCollection.js.map +1 -1
  12. package/dist/opLifecycle/duplicateBatchDetector.d.ts +39 -3
  13. package/dist/opLifecycle/duplicateBatchDetector.d.ts.map +1 -1
  14. package/dist/opLifecycle/duplicateBatchDetector.js +57 -15
  15. package/dist/opLifecycle/duplicateBatchDetector.js.map +1 -1
  16. package/dist/packageVersion.d.ts +1 -1
  17. package/dist/packageVersion.js +1 -1
  18. package/dist/packageVersion.js.map +1 -1
  19. package/dist/runtimeLayerCompatState.d.ts +2 -2
  20. package/dist/summary/documentSchema.d.ts.map +1 -1
  21. package/dist/summary/documentSchema.js +35 -3
  22. package/dist/summary/documentSchema.js.map +1 -1
  23. package/lib/containerRuntime.d.ts.map +1 -1
  24. package/lib/containerRuntime.js +48 -9
  25. package/lib/containerRuntime.js.map +1 -1
  26. package/lib/dataStoreContext.d.ts.map +1 -1
  27. package/lib/dataStoreContext.js +2 -5
  28. package/lib/dataStoreContext.js.map +1 -1
  29. package/lib/gc/garbageCollection.d.ts.map +1 -1
  30. package/lib/gc/garbageCollection.js +12 -5
  31. package/lib/gc/garbageCollection.js.map +1 -1
  32. package/lib/opLifecycle/duplicateBatchDetector.d.ts +39 -3
  33. package/lib/opLifecycle/duplicateBatchDetector.d.ts.map +1 -1
  34. package/lib/opLifecycle/duplicateBatchDetector.js +57 -15
  35. package/lib/opLifecycle/duplicateBatchDetector.js.map +1 -1
  36. package/lib/packageVersion.d.ts +1 -1
  37. package/lib/packageVersion.js +1 -1
  38. package/lib/packageVersion.js.map +1 -1
  39. package/lib/runtimeLayerCompatState.d.ts +2 -2
  40. package/lib/summary/documentSchema.d.ts.map +1 -1
  41. package/lib/summary/documentSchema.js +35 -3
  42. package/lib/summary/documentSchema.js.map +1 -1
  43. package/package.json +18 -18
  44. package/src/containerRuntime.ts +54 -10
  45. package/src/dataStoreContext.ts +2 -4
  46. package/src/gc/garbageCollection.ts +12 -5
  47. package/src/opLifecycle/duplicateBatchDetector.ts +103 -23
  48. package/src/packageVersion.ts +1 -1
  49. package/src/summary/documentSchema.ts +56 -3
@@ -840,14 +840,21 @@ export class GarbageCollector implements IGarbageCollector {
840
840
  const gcDataSuperSet = concatGarbageCollectionData(previousGCData, currentGCData);
841
841
  const newOutboundRoutesSinceLastRun: string[] = [];
842
842
  for (const [sourceNodeId, outboundRoutes] of this.newReferencesSinceLastRun) {
843
- if (gcDataSuperSet.gcNodes[sourceNodeId] === undefined) {
843
+ const target: string[] | undefined = gcDataSuperSet.gcNodes[sourceNodeId];
844
+ if (target === undefined) {
844
845
  gcDataSuperSet.gcNodes[sourceNodeId] = outboundRoutes;
845
846
  } else {
846
- // TODO: Fix this violation and remove the disable
847
- // eslint-disable-next-line @fluid-internal/fluid/no-unchecked-record-access
848
- gcDataSuperSet.gcNodes[sourceNodeId].push(...outboundRoutes);
847
+ // Avoid `push(...outboundRoutes)`: spreading a large array into a variadic call
848
+ // can exceed the engine's argument-count limit and throw RangeError.
849
+ for (const route of outboundRoutes) {
850
+ target.push(route);
851
+ }
852
+ }
853
+ // Avoid `push(...outboundRoutes)`: spreading a large array into a variadic call
854
+ // can exceed the engine's argument-count limit and throw RangeError.
855
+ for (const route of outboundRoutes) {
856
+ newOutboundRoutesSinceLastRun.push(route);
849
857
  }
850
- newOutboundRoutesSinceLastRun.push(...outboundRoutes);
851
858
  }
852
859
 
853
860
  /**
@@ -9,6 +9,41 @@ import type { ITelemetryContext } from "@fluidframework/runtime-definitions/inte
9
9
  import { getEffectiveBatchId } from "./batchManager.js";
10
10
  import type { BatchStartInfo } from "./remoteMessageProcessor.js";
11
11
 
12
+ /**
13
+ * Identifying info for a previously-recorded batch that we can include in DuplicateBatch telemetry
14
+ * to help diagnose where the duplicate came from.
15
+ *
16
+ * @remarks `batchIdExplicit` distinguishes the two main duplicate-source scenarios:
17
+ * - `true`: the batchId was stamped on the wire as explicit metadata, indicating a resubmit (PendingStateManager.replayPendingStates).
18
+ * - `false`: the batchId was derived from the wire `clientId` and `batchStartCsn`, indicating a fresh, non-resubmit batch.
19
+ */
20
+ export interface RecordedBatchInfo {
21
+ /**
22
+ * Wire clientId on the message that started the batch (NOT necessarily the `originalClientId`
23
+ * encoded in the batchId for resubmits).
24
+ */
25
+ readonly clientId: string;
26
+ /**
27
+ * Wire client sequence number at the start of the batch.
28
+ */
29
+ readonly batchStartCsn: number;
30
+ /**
31
+ * True if the batchId came from explicit metadata on the wire (i.e. a resubmit),
32
+ * false if it was derived from clientId + batchStartCsn (i.e. a fresh submit).
33
+ */
34
+ readonly batchIdExplicit: boolean;
35
+ }
36
+
37
+ interface RecordedBatch {
38
+ readonly batchId: string;
39
+ /**
40
+ * Identifying info for the batch as observed at runtime.
41
+ * `undefined` if the batch was loaded from a summary snapshot (where only the
42
+ * `[seqNum, batchId]` pair is persisted).
43
+ */
44
+ readonly info: RecordedBatchInfo | undefined;
45
+ }
46
+
12
47
  /**
13
48
  * Detects duplicate batches that can arise from the "parallel fork" scenario:
14
49
  * Container 1 is serialized, and Containers 2 and 3 are rehydrated from that state.
@@ -24,9 +59,21 @@ export class DuplicateBatchDetector {
24
59
  private readonly seqNumByBatchId = new Map<string, number>();
25
60
 
26
61
  /**
27
- * We map from sequenceNumber to batchId to find which ones we can stop tracking as MSN advances
62
+ * Map from sequenceNumber to the recorded batch info. Used to clear out old entries as MSN
63
+ * advances, and to report identifying info about the original occurrence when a duplicate
64
+ * is detected.
65
+ */
66
+ private readonly batchesBySeqNum = new Map<number, RecordedBatch>();
67
+
68
+ /**
69
+ * Number of inbound batches processed since the last summary. Reset by getRecentBatchInfoForSummary.
70
+ */
71
+ private processedBatchCount = 0;
72
+
73
+ /**
74
+ * Largest tracked-batch count observed since the last summary. Reset by getRecentBatchInfoForSummary.
28
75
  */
29
- private readonly batchIdsBySeqNum = new Map<number, string>();
76
+ private peakTrackedBatchCount = 0;
30
77
 
31
78
  /**
32
79
  * Initialize from snapshot data if provided - otherwise initialize empty
@@ -34,22 +81,31 @@ export class DuplicateBatchDetector {
34
81
  constructor(batchIdsFromSnapshot: [number, string][] | undefined) {
35
82
  if (batchIdsFromSnapshot) {
36
83
  for (const [seqNum, batchId] of batchIdsFromSnapshot) {
37
- this.batchIdsBySeqNum.set(seqNum, batchId);
84
+ // Entries loaded from a snapshot don't carry the original clientId/csn/explicit-bit;
85
+ // we record them with `info: undefined` so duplicate telemetry can indicate that.
86
+ this.batchesBySeqNum.set(seqNum, { batchId, info: undefined });
38
87
  this.seqNumByBatchId.set(batchId, seqNum);
39
88
  }
89
+ this.peakTrackedBatchCount = this.batchesBySeqNum.size;
40
90
  }
41
91
  }
42
92
 
43
93
  /**
44
94
  * Records this batch's batchId, and checks if it's a duplicate of a batch we've already seen.
45
- * If it's a duplicate, also return the sequence number of the other batch for logging.
95
+ * If it's a duplicate, also return the sequence number of the other batch (and identifying info,
96
+ * if the other batch was seen during this container session rather than loaded from snapshot) for logging.
46
97
  *
47
98
  * @remarks We also use the minimumSequenceNumber to clear out old batchIds that are no longer at risk for duplicates.
48
99
  */
49
- public processInboundBatch(
50
- batchStart: BatchStartInfo,
51
- ): { duplicate: true; otherSequenceNumber: number } | { duplicate: false } {
100
+ public processInboundBatch(batchStart: BatchStartInfo):
101
+ | {
102
+ duplicate: true;
103
+ otherSequenceNumber: number;
104
+ otherBatchInfo: RecordedBatchInfo | undefined;
105
+ }
106
+ | { duplicate: false } {
52
107
  const { sequenceNumber, minimumSequenceNumber } = batchStart.keyMessage;
108
+ this.processedBatchCount++;
53
109
 
54
110
  // Glance at this batch's MSN. Any batchIds we're tracking with a lower sequence number are now safe to forget.
55
111
  // Why? Because any other client holding the same batch locally would have seen the earlier batch and closed before submitting its duplicate.
@@ -64,22 +120,37 @@ export class DuplicateBatchDetector {
64
120
  // O(1) duplicate check + get otherSequenceNumber in one lookup
65
121
  const otherSequenceNumber = this.seqNumByBatchId.get(batchId);
66
122
  if (otherSequenceNumber !== undefined) {
123
+ const other = this.batchesBySeqNum.get(otherSequenceNumber);
67
124
  assert(
68
- this.batchIdsBySeqNum.get(otherSequenceNumber) === batchId,
125
+ other?.batchId === batchId,
69
126
  0xce0 /* batchIdToSeqNum and seqNumToBatchId should be in sync for duplicate */,
70
127
  );
71
- return { duplicate: true, otherSequenceNumber };
128
+ return {
129
+ duplicate: true,
130
+ otherSequenceNumber,
131
+ otherBatchInfo: other.info,
132
+ };
72
133
  }
73
134
 
74
135
  // Now we know it's not a duplicate, so add it to the tracked batchIds and return.
75
136
  assert(
76
- !this.batchIdsBySeqNum.has(sequenceNumber),
137
+ !this.batchesBySeqNum.has(sequenceNumber),
77
138
  0xce1 /* seqNumToBatchId and batchIdToSeqNum should be in sync */,
78
139
  );
79
140
 
80
- // Add new batch
81
- this.batchIdsBySeqNum.set(sequenceNumber, batchId);
141
+ // Add new batch. Record identifying info so we can report it if a future duplicate matches us.
142
+ const info: RecordedBatchInfo | undefined = {
143
+ clientId: batchStart.clientId,
144
+ batchStartCsn: batchStart.batchStartCsn,
145
+ // True iff the wire carried explicit batchId metadata (resubmit path).
146
+ // False indicates the batchId was derived from clientId + batchStartCsn (fresh submit).
147
+ batchIdExplicit: batchStart.batchId !== undefined,
148
+ };
149
+ this.batchesBySeqNum.set(sequenceNumber, { batchId, info });
82
150
  this.seqNumByBatchId.set(batchId, sequenceNumber);
151
+ if (this.batchesBySeqNum.size > this.peakTrackedBatchCount) {
152
+ this.peakTrackedBatchCount = this.batchesBySeqNum.size;
153
+ }
83
154
 
84
155
  return { duplicate: false };
85
156
  }
@@ -89,10 +160,10 @@ export class DuplicateBatchDetector {
89
160
  * since the batch start has been processed by all clients, and local batches are deduped and the forked client would close.
90
161
  */
91
162
  private clearOldBatchIds(msn: number): void {
92
- for (const [sequenceNumber, batchId] of this.batchIdsBySeqNum) {
163
+ for (const [sequenceNumber, recorded] of this.batchesBySeqNum) {
93
164
  if (sequenceNumber < msn) {
94
- this.batchIdsBySeqNum.delete(sequenceNumber);
95
- this.seqNumByBatchId.delete(batchId);
165
+ this.batchesBySeqNum.delete(sequenceNumber);
166
+ this.seqNumByBatchId.delete(recorded.batchId);
96
167
  } else {
97
168
  break;
98
169
  }
@@ -108,16 +179,25 @@ export class DuplicateBatchDetector {
108
179
  public getRecentBatchInfoForSummary(
109
180
  telemetryContext?: ITelemetryContext,
110
181
  ): [number, string][] | undefined {
111
- if (this.batchIdsBySeqNum.size === 0) {
112
- return undefined;
182
+ if (telemetryContext !== undefined) {
183
+ const prefix = "fluid_DuplicateBatchDetector_";
184
+ telemetryContext.set(prefix, "recentBatchCount", this.batchesBySeqNum.size);
185
+ telemetryContext.set(prefix, "peakRecentBatchCount", this.peakTrackedBatchCount);
186
+ telemetryContext.set(prefix, "processedBatchCount", this.processedBatchCount);
113
187
  }
114
188
 
115
- telemetryContext?.set(
116
- "fluid_DuplicateBatchDetector_",
117
- "recentBatchCount",
118
- this.batchIdsBySeqNum.size,
119
- );
189
+ // Reset per-window perf counters so each summary covers only the activity since the
190
+ // previous one. Peak resets to the current size (the floor for the next window).
191
+ this.processedBatchCount = 0;
192
+ this.peakTrackedBatchCount = this.batchesBySeqNum.size;
193
+
194
+ if (this.batchesBySeqNum.size === 0) {
195
+ return undefined;
196
+ }
120
197
 
121
- return [...this.batchIdsBySeqNum.entries()];
198
+ return [...this.batchesBySeqNum.entries()].map(([seqNum, recorded]) => [
199
+ seqNum,
200
+ recorded.batchId,
201
+ ]);
122
202
  }
123
203
  }
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  export const pkgName = "@fluidframework/container-runtime";
9
- export const pkgVersion = "2.101.1";
9
+ export const pkgVersion = "2.102.0";
@@ -303,6 +303,58 @@ const documentSchemaSupportedConfigs = {
303
303
  disallowedVersions: new CheckVersions(),
304
304
  };
305
305
 
306
+ /**
307
+ * Entry in {@link retiredDocumentSchemaFeatures}.
308
+ *
309
+ * - `handler` validates/merges the persisted value (same as a "non-retired" feature).
310
+ * - `value` is the hardcoded value that this runtime will always use for the desired schema.
311
+ */
312
+ interface IRetiredFeatureEntry {
313
+ handler: IProperty;
314
+ value: DocumentSchemaValueType;
315
+ }
316
+
317
+ /**
318
+ * Retired runtime features. A retired feature is one this runtime version no longer toggles
319
+ * via {@link IDocumentSchemaFeatures}, but that older documents may still carry in their
320
+ * persisted schema. Each entry bundles the property handler with the hardcoded value for the feature.
321
+ *
322
+ * Retired features participate in the normal merge / schema-change-op flow exactly like
323
+ * non-retired features — the only difference is that their values are hardcoded.
324
+ */
325
+ const retiredDocumentSchemaFeatures = {
326
+ // Note: There are currently no retired retired features. To retire a feature, remove it from IDocumentSchemaFeatures
327
+ // and documentSchemaSupportedConfigs and add an entry here, e.g.:
328
+ // featureFoo: { handler: new TrueOrUndefined(), value: true },
329
+ } satisfies Record<string, IRetiredFeatureEntry> & {
330
+ // This ensures that retiredDocumentSchemaFeatures and IDocumentSchemaFeatures are mutually exclusive.
331
+ [K in keyof IDocumentSchemaFeatures]?: never;
332
+ };
333
+
334
+ /**
335
+ * Looks up the validator/merger for a given runtime property name across both supported and
336
+ * retired configs. Returns `undefined` if the property is unknown to this runtime.
337
+ */
338
+ function getRuntimeConfigHandler(name: string): IProperty | undefined {
339
+ return (
340
+ (documentSchemaSupportedConfigs as Record<string, IProperty>)[name] ??
341
+ (retiredDocumentSchemaFeatures as Record<string, IRetiredFeatureEntry>)[name]?.handler
342
+ );
343
+ }
344
+
345
+ /**
346
+ * Builds the `{ key: value }` for retired features (for building the desired schema).
347
+ */
348
+ function retiredFeatureValues(): Record<string, DocumentSchemaValueType> {
349
+ const result: Record<string, DocumentSchemaValueType> = {};
350
+ for (const [key, entry] of Object.entries(
351
+ retiredDocumentSchemaFeatures as Record<string, IRetiredFeatureEntry>,
352
+ )) {
353
+ result[key] = entry.value;
354
+ }
355
+ return result;
356
+ }
357
+
306
358
  /**
307
359
  * Checks if a given schema is compatible with current code, i.e. if current code can understand all the features of that schema.
308
360
  * If schema is not compatible with current code, it throws an exception.
@@ -343,7 +395,7 @@ function checkRuntimeCompatibility(
343
395
  unknownProperty = "runtime";
344
396
  } else {
345
397
  for (const [name, value] of Object.entries(documentSchema.runtime)) {
346
- const validator = documentSchemaSupportedConfigs[name] as IProperty | undefined;
398
+ const validator = getRuntimeConfigHandler(name);
347
399
  if (!(validator?.validate(value) ?? false)) {
348
400
  unknownProperty = `runtime/${name}`;
349
401
  }
@@ -377,7 +429,7 @@ function and(
377
429
  ...Object.keys(persistedSchema.runtime),
378
430
  ...Object.keys(providedSchema.runtime),
379
431
  ])) {
380
- runtime[key] = (documentSchemaSupportedConfigs[key] as IProperty).and(
432
+ runtime[key] = (getRuntimeConfigHandler(key) as IProperty).and(
381
433
  persistedSchema.runtime[key],
382
434
  providedSchema.runtime[key],
383
435
  );
@@ -405,7 +457,7 @@ function or(
405
457
  ...Object.keys(persistedSchema.runtime),
406
458
  ...Object.keys(providedSchema.runtime),
407
459
  ])) {
408
- runtime[key] = (documentSchemaSupportedConfigs[key] as IProperty).or(
460
+ runtime[key] = (getRuntimeConfigHandler(key) as IProperty).or(
409
461
  persistedSchema.runtime[key],
410
462
  providedSchema.runtime[key],
411
463
  );
@@ -625,6 +677,7 @@ export class DocumentsSchemaController {
625
677
  opGroupingEnabled: boolToProp(features.opGroupingEnabled),
626
678
  createBlobPayloadPending: features.createBlobPayloadPending,
627
679
  disallowedVersions: arrayToProp(features.disallowedVersions),
680
+ ...retiredFeatureValues(),
628
681
  },
629
682
  };
630
683