@fluidframework/container-runtime 2.101.0 → 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 +26 -2
  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 +31 -3
  13. package/dist/opLifecycle/duplicateBatchDetector.d.ts.map +1 -1
  14. package/dist/opLifecycle/duplicateBatchDetector.js +39 -19
  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 +26 -2
  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 +31 -3
  33. package/lib/opLifecycle/duplicateBatchDetector.d.ts.map +1 -1
  34. package/lib/opLifecycle/duplicateBatchDetector.js +39 -19
  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 +28 -2
  45. package/src/dataStoreContext.ts +2 -4
  46. package/src/gc/garbageCollection.ts +12 -5
  47. package/src/opLifecycle/duplicateBatchDetector.ts +81 -22
  48. package/src/packageVersion.ts +1 -1
  49. package/src/summary/documentSchema.ts +56 -3
@@ -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,11 @@ 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.
28
65
  */
29
- private readonly batchIdsBySeqNum = new Map<number, string>();
66
+ private readonly batchesBySeqNum = new Map<number, RecordedBatch>();
30
67
 
31
68
  /**
32
69
  * Number of inbound batches processed since the last summary. Reset by getRecentBatchInfoForSummary.
@@ -44,22 +81,29 @@ export class DuplicateBatchDetector {
44
81
  constructor(batchIdsFromSnapshot: [number, string][] | undefined) {
45
82
  if (batchIdsFromSnapshot) {
46
83
  for (const [seqNum, batchId] of batchIdsFromSnapshot) {
47
- 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 });
48
87
  this.seqNumByBatchId.set(batchId, seqNum);
49
88
  }
50
- this.peakTrackedBatchCount = this.batchIdsBySeqNum.size;
89
+ this.peakTrackedBatchCount = this.batchesBySeqNum.size;
51
90
  }
52
91
  }
53
92
 
54
93
  /**
55
94
  * Records this batch's batchId, and checks if it's a duplicate of a batch we've already seen.
56
- * 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.
57
97
  *
58
98
  * @remarks We also use the minimumSequenceNumber to clear out old batchIds that are no longer at risk for duplicates.
59
99
  */
60
- public processInboundBatch(
61
- batchStart: BatchStartInfo,
62
- ): { 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 } {
63
107
  const { sequenceNumber, minimumSequenceNumber } = batchStart.keyMessage;
64
108
  this.processedBatchCount++;
65
109
 
@@ -76,24 +120,36 @@ export class DuplicateBatchDetector {
76
120
  // O(1) duplicate check + get otherSequenceNumber in one lookup
77
121
  const otherSequenceNumber = this.seqNumByBatchId.get(batchId);
78
122
  if (otherSequenceNumber !== undefined) {
123
+ const other = this.batchesBySeqNum.get(otherSequenceNumber);
79
124
  assert(
80
- this.batchIdsBySeqNum.get(otherSequenceNumber) === batchId,
125
+ other?.batchId === batchId,
81
126
  0xce0 /* batchIdToSeqNum and seqNumToBatchId should be in sync for duplicate */,
82
127
  );
83
- return { duplicate: true, otherSequenceNumber };
128
+ return {
129
+ duplicate: true,
130
+ otherSequenceNumber,
131
+ otherBatchInfo: other.info,
132
+ };
84
133
  }
85
134
 
86
135
  // Now we know it's not a duplicate, so add it to the tracked batchIds and return.
87
136
  assert(
88
- !this.batchIdsBySeqNum.has(sequenceNumber),
137
+ !this.batchesBySeqNum.has(sequenceNumber),
89
138
  0xce1 /* seqNumToBatchId and batchIdToSeqNum should be in sync */,
90
139
  );
91
140
 
92
- // Add new batch
93
- 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 });
94
150
  this.seqNumByBatchId.set(batchId, sequenceNumber);
95
- if (this.batchIdsBySeqNum.size > this.peakTrackedBatchCount) {
96
- this.peakTrackedBatchCount = this.batchIdsBySeqNum.size;
151
+ if (this.batchesBySeqNum.size > this.peakTrackedBatchCount) {
152
+ this.peakTrackedBatchCount = this.batchesBySeqNum.size;
97
153
  }
98
154
 
99
155
  return { duplicate: false };
@@ -104,10 +160,10 @@ export class DuplicateBatchDetector {
104
160
  * since the batch start has been processed by all clients, and local batches are deduped and the forked client would close.
105
161
  */
106
162
  private clearOldBatchIds(msn: number): void {
107
- for (const [sequenceNumber, batchId] of this.batchIdsBySeqNum) {
163
+ for (const [sequenceNumber, recorded] of this.batchesBySeqNum) {
108
164
  if (sequenceNumber < msn) {
109
- this.batchIdsBySeqNum.delete(sequenceNumber);
110
- this.seqNumByBatchId.delete(batchId);
165
+ this.batchesBySeqNum.delete(sequenceNumber);
166
+ this.seqNumByBatchId.delete(recorded.batchId);
111
167
  } else {
112
168
  break;
113
169
  }
@@ -125,7 +181,7 @@ export class DuplicateBatchDetector {
125
181
  ): [number, string][] | undefined {
126
182
  if (telemetryContext !== undefined) {
127
183
  const prefix = "fluid_DuplicateBatchDetector_";
128
- telemetryContext.set(prefix, "recentBatchCount", this.batchIdsBySeqNum.size);
184
+ telemetryContext.set(prefix, "recentBatchCount", this.batchesBySeqNum.size);
129
185
  telemetryContext.set(prefix, "peakRecentBatchCount", this.peakTrackedBatchCount);
130
186
  telemetryContext.set(prefix, "processedBatchCount", this.processedBatchCount);
131
187
  }
@@ -133,12 +189,15 @@ export class DuplicateBatchDetector {
133
189
  // Reset per-window perf counters so each summary covers only the activity since the
134
190
  // previous one. Peak resets to the current size (the floor for the next window).
135
191
  this.processedBatchCount = 0;
136
- this.peakTrackedBatchCount = this.batchIdsBySeqNum.size;
192
+ this.peakTrackedBatchCount = this.batchesBySeqNum.size;
137
193
 
138
- if (this.batchIdsBySeqNum.size === 0) {
194
+ if (this.batchesBySeqNum.size === 0) {
139
195
  return undefined;
140
196
  }
141
197
 
142
- return [...this.batchIdsBySeqNum.entries()];
198
+ return [...this.batchesBySeqNum.entries()].map(([seqNum, recorded]) => [
199
+ seqNum,
200
+ recorded.batchId,
201
+ ]);
143
202
  }
144
203
  }
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  export const pkgName = "@fluidframework/container-runtime";
9
- export const pkgVersion = "2.101.0";
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