@fluidframework/id-compressor 3.2.0 → 3.4.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 (102) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/api-report/id-compressor.legacy.beta.api.md +0 -49
  3. package/dist/idCompressor.d.ts +65 -22
  4. package/dist/idCompressor.d.ts.map +1 -1
  5. package/dist/idCompressor.js +401 -60
  6. package/dist/idCompressor.js.map +1 -1
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/legacy.d.ts +1 -12
  12. package/dist/packageVersion.d.ts +1 -1
  13. package/dist/packageVersion.js +1 -1
  14. package/dist/packageVersion.js.map +1 -1
  15. package/dist/test/idCompressor.perf.spec.js.map +1 -1
  16. package/dist/test/idCompressor.spec.js.map +1 -1
  17. package/dist/test/idCompressorTestUtilities.d.ts.map +1 -1
  18. package/dist/test/idCompressorTestUtilities.js +8 -3
  19. package/dist/test/idCompressorTestUtilities.js.map +1 -1
  20. package/dist/test/sharding.fuzz.spec.d.ts +6 -0
  21. package/dist/test/sharding.fuzz.spec.d.ts.map +1 -0
  22. package/dist/test/sharding.fuzz.spec.js.map +1 -0
  23. package/dist/test/sharding.spec.d.ts +6 -0
  24. package/dist/test/sharding.spec.d.ts.map +1 -0
  25. package/dist/test/sharding.spec.js.map +1 -0
  26. package/dist/test/snapshots/summary.spec.js.map +1 -1
  27. package/dist/test/types/validateIdCompressorPrevious.generated.js.map +1 -1
  28. package/dist/types/idCompressor.d.ts +205 -20
  29. package/dist/types/idCompressor.d.ts.map +1 -1
  30. package/dist/types/idCompressor.js +15 -0
  31. package/dist/types/idCompressor.js.map +1 -1
  32. package/dist/types/index.d.ts +2 -1
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/dist/types/index.js +3 -0
  35. package/dist/types/index.js.map +1 -1
  36. package/dist/types/persisted-types/0.0.1.d.ts +4 -4
  37. package/dist/types/persisted-types/0.0.1.js.map +1 -1
  38. package/dist/utilities.d.ts +1 -1
  39. package/dist/utilities.js +1 -1
  40. package/dist/utilities.js.map +1 -1
  41. package/lib/idCompressor.d.ts +65 -22
  42. package/lib/idCompressor.d.ts.map +1 -1
  43. package/lib/idCompressor.js +402 -61
  44. package/lib/idCompressor.js.map +1 -1
  45. package/lib/index.d.ts +2 -1
  46. package/lib/index.d.ts.map +1 -1
  47. package/lib/index.js +1 -0
  48. package/lib/index.js.map +1 -1
  49. package/lib/legacy.d.ts +1 -12
  50. package/lib/packageVersion.d.ts +1 -1
  51. package/lib/packageVersion.js +1 -1
  52. package/lib/packageVersion.js.map +1 -1
  53. package/lib/test/idCompressor.perf.spec.js.map +1 -1
  54. package/lib/test/idCompressor.spec.js.map +1 -1
  55. package/lib/test/idCompressorTestUtilities.d.ts.map +1 -1
  56. package/lib/test/idCompressorTestUtilities.js +8 -3
  57. package/lib/test/idCompressorTestUtilities.js.map +1 -1
  58. package/lib/test/sharding.fuzz.spec.d.ts +6 -0
  59. package/lib/test/sharding.fuzz.spec.d.ts.map +1 -0
  60. package/lib/test/sharding.fuzz.spec.js.map +1 -0
  61. package/lib/test/sharding.spec.d.ts +6 -0
  62. package/lib/test/sharding.spec.d.ts.map +1 -0
  63. package/lib/test/sharding.spec.js.map +1 -0
  64. package/lib/test/snapshots/summary.spec.js.map +1 -1
  65. package/lib/test/types/validateIdCompressorPrevious.generated.js.map +1 -1
  66. package/lib/types/idCompressor.d.ts +205 -20
  67. package/lib/types/idCompressor.d.ts.map +1 -1
  68. package/lib/types/idCompressor.js +14 -1
  69. package/lib/types/idCompressor.js.map +1 -1
  70. package/lib/types/index.d.ts +2 -1
  71. package/lib/types/index.d.ts.map +1 -1
  72. package/lib/types/index.js +1 -1
  73. package/lib/types/index.js.map +1 -1
  74. package/lib/types/persisted-types/0.0.1.d.ts +4 -4
  75. package/lib/types/persisted-types/0.0.1.js.map +1 -1
  76. package/lib/utilities.d.ts +1 -1
  77. package/lib/utilities.js +1 -1
  78. package/lib/utilities.js.map +1 -1
  79. package/package.json +8 -8
  80. package/src/idCompressor.ts +550 -78
  81. package/src/index.ts +11 -5
  82. package/src/packageVersion.ts +1 -1
  83. package/src/test/idCompressorTestUtilities.ts +16 -3
  84. package/src/test/snapshots/v2/compressor-with-finalized-range-from-one-client +1 -0
  85. package/src/test/snapshots/v2/compressor-with-finalized-range-from-one-client-client2 +1 -0
  86. package/src/test/snapshots/v2/compressors-with-finalized-ranges-from-two-clients +1 -0
  87. package/src/test/snapshots/v2/compressors-with-finalized-ranges-from-two-clients-client2 +1 -0
  88. package/src/test/snapshots/v2/empty-compressor-summary +1 -0
  89. package/src/test/snapshots/v2/expansion-semantics +1 -0
  90. package/src/test/snapshots/v2/expansion-semantics-client2 +1 -0
  91. package/src/test/snapshots/v3/compressor-with-finalized-range-from-one-client +1 -0
  92. package/src/test/snapshots/v3/compressor-with-finalized-range-from-one-client-client2 +1 -0
  93. package/src/test/snapshots/v3/compressors-with-finalized-ranges-from-two-clients +1 -0
  94. package/src/test/snapshots/v3/compressors-with-finalized-ranges-from-two-clients-client2 +1 -0
  95. package/src/test/snapshots/v3/empty-compressor-summary +1 -0
  96. package/src/test/snapshots/v3/expansion-semantics +1 -0
  97. package/src/test/snapshots/v3/expansion-semantics-client2 +1 -0
  98. package/src/test/types/validateIdCompressorPrevious.generated.ts +1 -109
  99. package/src/types/idCompressor.ts +225 -20
  100. package/src/types/index.ts +13 -1
  101. package/src/types/persisted-types/0.0.1.ts +4 -4
  102. package/src/utilities.ts +1 -1
@@ -3,8 +3,8 @@
3
3
  * Licensed under the MIT License.
4
4
  */
5
5
 
6
- import { bufferToString, stringToBuffer } from "@fluid-internal/client-utils";
7
- import type { ITelemetryBaseLogger } from "@fluidframework/core-interfaces";
6
+ import { bufferToString, createEmitter, stringToBuffer } from "@fluid-internal/client-utils";
7
+ import type { ITelemetryBaseLogger, Listenable } from "@fluidframework/core-interfaces";
8
8
  import { assert } from "@fluidframework/core-utils/internal";
9
9
  import type { TelemetryLoggerExt } from "@fluidframework/telemetry-utils/internal";
10
10
  import {
@@ -42,6 +42,7 @@ import {
42
42
  lastFinalizedLocal,
43
43
  } from "./sessions.js";
44
44
  import type {
45
+ ShardToken,
45
46
  IIdCompressor,
46
47
  IIdCompressorCore,
47
48
  IdCreationRange,
@@ -52,7 +53,12 @@ import type {
52
53
  SessionId,
53
54
  SessionSpaceCompressedId,
54
55
  StableId,
56
+ ShardSynchronizationToken,
57
+ SerializedIdCompressorShard,
58
+ ParentShardSynchronizationToken,
59
+ IdCompressorEvents,
55
60
  } from "./types/index.js";
61
+ import { SerializationVersion } from "./types/index.js";
56
62
  import {
57
63
  createSessionId,
58
64
  genCountFromLocalId,
@@ -63,12 +69,6 @@ import {
63
69
  subtractNumericUuids,
64
70
  } from "./utilities.js";
65
71
 
66
- /**
67
- * The version of IdCompressor that is currently persisted.
68
- * This should not be changed without careful consideration to compatibility.
69
- */
70
- const currentWrittenVersion = 2;
71
-
72
72
  function rangeFinalizationError(expectedStart: number, actualStart: number): LoggingError {
73
73
  return new LoggingError("Ranges finalized out of order", {
74
74
  expectedStart,
@@ -76,10 +76,28 @@ function rangeFinalizationError(expectedStart: number, actualStart: number): Log
76
76
  });
77
77
  }
78
78
 
79
+ const MAX_STRIDE_LENGTH = 1000000;
80
+
81
+ /**
82
+ * Validates the generation count in a shard synchronization token.
83
+ *
84
+ * @param localGenCount - The number of positions filled, including backfilled positions.
85
+ * @throws {@link TypeError} if the count is not a nonnegative safe integer.
86
+ */
87
+ function validateShardGenerationCount(localGenCount: number): void {
88
+ if (!Number.isSafeInteger(localGenCount) || localGenCount < 0) {
89
+ throw new TypeError("Invalid shard synchronization token generation count.");
90
+ }
91
+ }
92
+
79
93
  /**
80
94
  * See {@link IIdCompressor} and {@link IIdCompressorCore}
81
95
  */
82
96
  export class IdCompressor implements IIdCompressor, IIdCompressorCore {
97
+ private readonly eventEmitter = createEmitter<IdCompressorEvents>();
98
+
99
+ public readonly events: Listenable<IdCompressorEvents> = this.eventEmitter;
100
+
83
101
  /**
84
102
  * Max allowed initial cluster size.
85
103
  */
@@ -93,13 +111,51 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
93
111
  // The number of IDs generated by the local session
94
112
  private localGenCount = 0;
95
113
 
114
+ /**
115
+ * The sharding state of this compressor, if it has been sharded or is a shard itself.
116
+ * When defined, this compressor operates in "sharding mode" where:
117
+ * - IDs are generated using stride-based allocation (no eager finals)
118
+ * - The normalizer backfills all IDs in each stride cycle
119
+ * - Normal operation resumes when all children are disposed
120
+ */
121
+ private shardingState?:
122
+ | {
123
+ /**
124
+ * Current stride - used when this compressor has active children.
125
+ * Changes each time shard() is called.
126
+ */
127
+ currentStride: number;
128
+
129
+ /**
130
+ * Original stride - the stride this compressor was created with.
131
+ * Used when this compressor has no children (is a leaf).
132
+ * IMMUTABLE after creation.
133
+ */
134
+ originalStride: number;
135
+
136
+ /**
137
+ * Set of unique child shard IDs (UUIDs) currently active.
138
+ * Each child is assigned a UUID when created via shard().
139
+ * Safe across serialization/deserialization cycles.
140
+ */
141
+ activeChildIds: Set<SessionId>;
142
+
143
+ /**
144
+ * This compressor's unique shard ID within its parent (if it's a child).
145
+ * Undefined for root compressors.
146
+ * Assigned by parent when creating this shard.
147
+ */
148
+ shardId?: SessionId;
149
+ }
150
+ | undefined;
151
+
96
152
  // #endregion
97
153
 
98
154
  // #region Final state
99
155
 
100
156
  /**
101
157
  * The gen count to be annotated on the range returned by the next call to `takeNextCreationRange`.
102
- * This is advanced to `generatedIdCount` + 1 each time it is called.
158
+ * This is advanced to `localGenCount` + 1 each time it is called.
103
159
  * On the other hand, when `resetUnfinalizedCreationRange` is called,
104
160
  * this is moved back to the start of the unfinalized range, to ensure those IDs are included in the next range.
105
161
  */
@@ -131,6 +187,12 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
131
187
  public constructor(
132
188
  localSessionIdOrDeserialized: SessionId | Sessions,
133
189
  private readonly logger: TelemetryLoggerExt | undefined,
190
+ /**
191
+ * The version this compressor will write out.
192
+ * This applies to both `serialize` and creation range functions.
193
+ * This number also determines what features are available (e.g. sharding support starts at V3).
194
+ */
195
+ private readonly writeVersion: SerializationVersion,
134
196
  ) {
135
197
  if (typeof localSessionIdOrDeserialized === "string") {
136
198
  this.localSessionId = localSessionIdOrDeserialized;
@@ -164,30 +226,42 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
164
226
  return lastFinalizedFinal(
165
227
  this.ongoingGhostSession.cluster,
166
228
  ) as unknown as SessionSpaceCompressedId;
167
- } else {
168
- this.localGenCount++;
169
- const lastCluster = this.localSession.getLastCluster();
170
- if (lastCluster === undefined) {
171
- this.telemetryLocalIdCount++;
172
- return this.generateNextLocalId();
173
- }
229
+ }
174
230
 
175
- // If there exists a cluster of final IDs already claimed by the local session that still has room in it,
176
- // it is known prior to range sequencing what a local ID's corresponding final ID will be.
177
- // In this case, it is safe to return the final ID immediately. This is guaranteed to be safe because
178
- // any op that the local session sends that contains one of those final IDs are guaranteed to arrive to
179
- // collaborators *after* the one containing the creation range.
180
- const clusterOffset = this.localGenCount - genCountFromLocalId(lastCluster.baseLocalId);
181
- if (lastCluster.capacity > clusterOffset) {
182
- this.telemetryEagerFinalIdCount++;
183
- // Space in the cluster: eager final
184
- return ((lastCluster.baseFinalId as number) +
185
- clusterOffset) as SessionSpaceCompressedId;
186
- }
187
- // No space in the cluster, return next local
231
+ // In sharding mode, always generate local IDs with stride-based allocation.
232
+ // Eager finals are disabled because shards backfill IDs not in their stride cycle.
233
+ // If another shard generated them as eager finals, ingesting IDs from another shard
234
+ // would fail depending on timing.
235
+ if (this.shardingState !== undefined) {
236
+ this.telemetryLocalIdCount++;
237
+ const { currentStride } = this.shardingState;
238
+ this.normalizer.addLocalRange(this.localGenCount + 1, currentStride);
239
+ this.localGenCount += currentStride;
240
+ return localIdFromGenCount(this.localGenCount);
241
+ }
242
+
243
+ // Normal ID generation
244
+ this.localGenCount++;
245
+ const lastCluster = this.localSession.getLastCluster();
246
+ if (lastCluster === undefined) {
188
247
  this.telemetryLocalIdCount++;
189
248
  return this.generateNextLocalId();
190
249
  }
250
+
251
+ // If there exists a cluster of final IDs already claimed by the local session that still has room in it,
252
+ // it is known prior to range sequencing what a local ID's corresponding final ID will be.
253
+ // In this case, it is safe to return the final ID immediately. This is guaranteed to be safe because
254
+ // any op that the local session sends that contains one of those final IDs are guaranteed to arrive to
255
+ // collaborators *after* the one containing the creation range.
256
+ const clusterOffset = this.localGenCount - genCountFromLocalId(lastCluster.baseLocalId);
257
+ if (lastCluster.capacity > clusterOffset) {
258
+ this.telemetryEagerFinalIdCount++;
259
+ // Space in the cluster: eager final
260
+ return ((lastCluster.baseFinalId as number) + clusterOffset) as SessionSpaceCompressedId;
261
+ }
262
+ // No space in the cluster, return next local
263
+ this.telemetryLocalIdCount++;
264
+ return this.generateNextLocalId();
191
265
  }
192
266
 
193
267
  public generateDocumentUniqueId():
@@ -206,10 +280,10 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
206
280
  this.ongoingGhostSession = { ghostSessionId };
207
281
  }
208
282
 
209
- /**
210
- * {@inheritdoc IIdCompressorCore.beginGhostSession}
211
- */
212
283
  public beginGhostSession(ghostSessionId: SessionId, ghostSessionCallback: () => void): void {
284
+ if (this.shardingState) {
285
+ throw new Error("Cannot start a ghost session while sharded.");
286
+ }
213
287
  this.startGhostSession(ghostSessionId);
214
288
  try {
215
289
  ghostSessionCallback();
@@ -218,6 +292,233 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
218
292
  }
219
293
  }
220
294
 
295
+ public shard(newShardCount: number): SerializedIdCompressorShard[] {
296
+ if (!Number.isSafeInteger(newShardCount) || newShardCount <= 0) {
297
+ throw new Error("Shard count must be a positive safe integer");
298
+ }
299
+ if (this.ongoingGhostSession) {
300
+ throw new Error("Cannot shard during ghost session");
301
+ }
302
+ if (this.writeVersion < SerializationVersion.V3) {
303
+ throw new Error(
304
+ `Sharding requires document version ${SerializationVersion.V3} or higher, but this document is version ${this.writeVersion}`,
305
+ );
306
+ }
307
+
308
+ const currentStride = this.shardingState?.currentStride ?? 1;
309
+ const newStride = currentStride * (newShardCount + 1);
310
+
311
+ // ID usage is exponential in stride count. Limit to practical size to avoid overflow.
312
+ if (newStride > MAX_STRIDE_LENGTH) {
313
+ throw new Error("Sharding limit reached.");
314
+ }
315
+ assert(Number.isSafeInteger(newStride), 0xd52 /* Shard stride must be a safe integer */);
316
+
317
+ if (this.shardingState === undefined) {
318
+ // First time sharding - initialize state
319
+ this.shardingState = {
320
+ currentStride: newStride,
321
+ originalStride: 1,
322
+ activeChildIds: new Set(),
323
+ };
324
+ } else {
325
+ this.shardingState.currentStride = newStride;
326
+ }
327
+
328
+ // Cache to avoid repeating per child
329
+ const serialized = this.serialize(true);
330
+ const { localGenCount: parentGenCount } = this;
331
+
332
+ const shards: SerializedIdCompressorShard[] = [];
333
+
334
+ // Children are positioned at consecutive positions within the parents current stride cycle
335
+ // e.g. if parent owns the "evens" space (-2, -4, -6, ...) and is sharded into 3 (two children)
336
+ // while at -8, then the child shards will start at -10 and -12 with a stride of 6
337
+ for (let i = 1; i <= newShardCount; i++) {
338
+ const childShardId = createSessionId();
339
+ this.shardingState.activeChildIds.add(childShardId);
340
+
341
+ const childLocalGenCount = parentGenCount + i * currentStride;
342
+
343
+ // This code results in a double roundtrip, but is probably plenty fast enough for scenarios that need sharding,
344
+ // as they necessarily have process boundaries to deal with.
345
+ // If this ever became an issue we can implement a fast clone of compressors
346
+ const child = deserializeIdCompressor(serialized, this.writeVersion) as IdCompressor;
347
+ const genCountJump = childLocalGenCount - parentGenCount;
348
+ assert(
349
+ child.localGenCount === parentGenCount && genCountJump > 0,
350
+ 0xd53 /* Child offsets incorrectly calculated. */,
351
+ );
352
+
353
+ child.normalizer.addLocalRange(child.localGenCount + 1, genCountJump);
354
+ child.localGenCount = childLocalGenCount;
355
+ child.shardingState = {
356
+ currentStride: newStride,
357
+ originalStride: newStride,
358
+ activeChildIds: new Set(),
359
+ shardId: childShardId,
360
+ };
361
+
362
+ shards.push({
363
+ serialized: child.serialize(true),
364
+ syncToken: child.makeShardToken(false),
365
+ });
366
+ }
367
+
368
+ return shards;
369
+ }
370
+
371
+ public synchronizeWithShard(syncToken: ShardSynchronizationToken): void {
372
+ const isNowLeaf = this.synchronizeChild(syncToken);
373
+ // A disposal token additionally reclaims the child's ID space. If reclaiming the last child
374
+ // returns this compressor to a leaf and it is the root (originalStride === 1), exit sharding mode.
375
+ if (syncToken.disposed) {
376
+ assert(this.shardingState !== undefined, 0xd54 /* Must be sharded */);
377
+ if (isNowLeaf && this.shardingState.originalStride === 1) {
378
+ this.shardingState = undefined;
379
+ }
380
+ }
381
+ }
382
+
383
+ public getChildShardSyncToken(
384
+ childToken: ShardSynchronizationToken,
385
+ ): ParentShardSynchronizationToken {
386
+ if (!this.shardingState?.activeChildIds.has(childToken.shardId)) {
387
+ throw new Error("Cannot get a synchronization token for an inactive child shard.");
388
+ }
389
+ const token: ParentShardSynchronizationToken = {
390
+ type: "parentIdSpaceShardSyncToken",
391
+ shardId: childToken.shardId,
392
+ localGenCount: this.localGenCount,
393
+ };
394
+ return token;
395
+ }
396
+
397
+ public synchronizeWithParent(token: ParentShardSynchronizationToken): void {
398
+ const state = this.shardingState;
399
+ if (
400
+ token.type !== "parentIdSpaceShardSyncToken" ||
401
+ state?.shardId === undefined ||
402
+ state.shardId !== token.shardId
403
+ ) {
404
+ throw new Error("Invalid parent synchronization token for this child shard.");
405
+ }
406
+ validateShardGenerationCount(token.localGenCount);
407
+ if (token.localGenCount <= this.localGenCount) {
408
+ // A child can already be ahead through its own allocations. Its local range
409
+ // then includes the parent's IDs through this generation count.
410
+ return;
411
+ }
412
+ this.backfillToNextStridePosition(token.localGenCount, state.currentStride);
413
+ }
414
+
415
+ /**
416
+ * Adds IDs through the next position in this shard's stride after the given generation count.
417
+ */
418
+ private backfillToNextStridePosition(targetGenCount: number, stride: number): void {
419
+ if (targetGenCount <= this.localGenCount) {
420
+ return;
421
+ }
422
+ const count = Math.ceil((targetGenCount - this.localGenCount + 1) / stride) * stride;
423
+ const nextGenCount = this.localGenCount + count;
424
+ if (!Number.isSafeInteger(nextGenCount)) {
425
+ throw new TypeError("Shard progress exceeds the supported ID space.");
426
+ }
427
+ this.normalizer.addLocalRange(this.localGenCount + 1, count);
428
+ this.localGenCount = nextGenCount;
429
+ }
430
+
431
+ private synchronizeChild(syncToken: ShardToken): boolean {
432
+ const childDisposed = syncToken.disposed;
433
+ if (this.writeVersion < SerializationVersion.V3) {
434
+ throw new Error(
435
+ `Sharding requires document version ${SerializationVersion.V3} or higher, but this document is version ${this.writeVersion}`,
436
+ );
437
+ }
438
+ if (this.shardingState === undefined) {
439
+ throw new Error("Must be in sharding mode.");
440
+ }
441
+
442
+ const childShardId = syncToken.shardId;
443
+ const { activeChildIds, originalStride } = this.shardingState;
444
+
445
+ // Verify this child belongs to us
446
+ if (!activeChildIds.has(childShardId)) {
447
+ throw new Error(
448
+ `Cannot synchronize with child with ID ${childShardId}: not in active children set`,
449
+ );
450
+ }
451
+ validateShardGenerationCount(syncToken.localGenCount);
452
+
453
+ // A last-child disposal realigns on the original stride. Backfill before changing
454
+ // membership so invalid progress cannot accidentally reclaim the child.
455
+ const isLeaf = childDisposed && activeChildIds.size === 1;
456
+ this.backfillToNextStridePosition(
457
+ syncToken.localGenCount,
458
+ isLeaf ? originalStride : this.shardingState.currentStride,
459
+ );
460
+
461
+ // A plain synchronization leaves the child active so it can keep generating IDs.
462
+ if (childDisposed) {
463
+ activeChildIds.delete(childShardId);
464
+ if (isLeaf) {
465
+ this.shardingState.currentStride = originalStride;
466
+ }
467
+ }
468
+
469
+ return isLeaf;
470
+ }
471
+
472
+ public disposeShard(): ShardSynchronizationToken | undefined {
473
+ if (this.shardingState === undefined) {
474
+ return undefined;
475
+ } else {
476
+ if (this.shardingState.activeChildIds.size > 0) {
477
+ throw new Error("Cannot dispose a shard with active children.");
478
+ }
479
+ }
480
+
481
+ // A disposal token is a sync token that additionally signals reclamation of this shard's ID space.
482
+ const token = this.makeShardToken(true);
483
+
484
+ // Make this compressor unusable by replacing all methods with throwing stubs
485
+ // This is a one-time mutation that avoids runtime checks in every method
486
+ makeCompressorUnusable(this);
487
+
488
+ return token;
489
+ }
490
+
491
+ public getShardSyncToken(): ShardSynchronizationToken | undefined {
492
+ if (this.shardingState === undefined) {
493
+ return undefined;
494
+ }
495
+
496
+ return this.makeShardToken(false);
497
+ }
498
+
499
+ /**
500
+ * Builds a token describing this shard's current progress.
501
+ * @param disposed - Whether the token also signals disposal (reclamation of this shard's ID space).
502
+ */
503
+ private makeShardToken(disposed: boolean): ShardSynchronizationToken {
504
+ assert(this.shardingState !== undefined, 0xd55 /* Compressor is not sharded. */);
505
+
506
+ // Root cannot produce a token (shardId is undefined for root)
507
+ if (this.shardingState.shardId === undefined) {
508
+ throw new Error(
509
+ "Cannot get a token for a root compressor - only shards can be disposed",
510
+ );
511
+ }
512
+
513
+ const token: ShardToken = {
514
+ localGenCount: this.localGenCount,
515
+ shardId: this.shardingState.shardId,
516
+ disposed,
517
+ };
518
+
519
+ return token as unknown as ShardSynchronizationToken;
520
+ }
521
+
221
522
  private generateNextLocalId(): LocalCompressedId {
222
523
  // Must tell the normalizer that we generated a local ID
223
524
  this.normalizer.addLocalRange(this.localGenCount, 1);
@@ -225,6 +526,14 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
225
526
  }
226
527
 
227
528
  public takeNextCreationRange(): IdCreationRange {
529
+ // Child (non-root) shards must not submit their own creation ranges. All shards in a shard tree share a
530
+ // single session, and only the root shard is responsible for finalizing that session's IDs with the server.
531
+ // A child's generated IDs are reconciled with the root via shard synchronization tokens (see
532
+ // synchronizeWithShard / disposeShard), not by independently sending ranges, which would overlap and
533
+ // conflict with the root's ranges for the shared session.
534
+ if (this.shardingState?.shardId !== undefined) {
535
+ throw new Error("Cannot take a creation range from a non-root shard.");
536
+ }
228
537
  assert(
229
538
  !this.ongoingGhostSession,
230
539
  0x8a6 /* IdCompressor should not be operated normally when in a ghost session */,
@@ -377,6 +686,7 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
377
686
  }
378
687
 
379
688
  assert(!session.isEmpty(), 0x757 /* Empty sessions should not be created. */);
689
+ this.eventEmitter.emit("rangeFinalized", range);
380
690
  }
381
691
 
382
692
  private addEmptyCluster(session: Session, capacity: number): IdCluster {
@@ -577,11 +887,24 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
577
887
  sessionIndex++;
578
888
  }
579
889
  }
890
+ const sizeOfUuid = 2;
891
+ const shardingStateSize =
892
+ hasLocalState && this.writeVersion >= SerializationVersion.V3
893
+ ? this.shardingState === undefined
894
+ ? 1 // Boolean: no sharding state
895
+ : 1 + // Boolean: has sharding state
896
+ 2 + // Numbers: currentStride, originalStride (Float64)
897
+ 1 + // Number: activeChildIds count (Float64)
898
+ this.shardingState.activeChildIds.size * sizeOfUuid + // UUIDs: each is 128-bit
899
+ 1 + // Boolean: hasShardId
900
+ (this.shardingState.shardId === undefined ? 0 : sizeOfUuid) // UUID: 128-bit if present
901
+ : 0;
580
902
  const localStateSize = hasLocalState
581
903
  ? 1 + // generated ID count
582
904
  1 + // next range base genCount
583
905
  1 + // count of normalizer pairs
584
- normalizer.idRanges.size * 2 // pairs
906
+ normalizer.idRanges.size * 2 + // pairs
907
+ shardingStateSize // sharding state if present
585
908
  : 0;
586
909
  // Layout size, in 8 byte increments
587
910
  const totalSize =
@@ -589,14 +912,14 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
589
912
  1 + // hasLocalState
590
913
  1 + // session count
591
914
  1 + // cluster count
592
- sessionIndexMap.size * 2 + // session IDs
915
+ sessionIndexMap.size * sizeOfUuid + // session IDs
593
916
  finalSpace.clusters.length * 3 + // clusters: (sessionIndex, capacity, count)[]
594
917
  localStateSize; // local state, if present
595
918
 
596
919
  const serializedFloat = new Float64Array(totalSize);
597
920
  const serializedUint = new BigUint64Array(serializedFloat.buffer);
598
921
  let index = 0;
599
- index = writeNumber(serializedFloat, index, currentWrittenVersion);
922
+ index = writeNumber(serializedFloat, index, this.writeVersion);
600
923
  index = writeBoolean(serializedFloat, index, hasLocalState);
601
924
  index = writeNumber(serializedFloat, index, sessionIndexMap.size);
602
925
  index = writeNumber(serializedFloat, index, finalSpace.clusters.length);
@@ -623,6 +946,35 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
623
946
  index = writeNumber(serializedFloat, index, leadingGenCount);
624
947
  index = writeNumber(serializedFloat, index, count);
625
948
  }
949
+
950
+ // Write sharding state (version 3+)
951
+ if (this.writeVersion >= SerializationVersion.V3) {
952
+ index = writeBoolean(serializedFloat, index, this.shardingState !== undefined);
953
+ if (this.shardingState !== undefined) {
954
+ index = writeNumber(serializedFloat, index, this.shardingState.currentStride);
955
+ index = writeNumber(serializedFloat, index, this.shardingState.originalStride);
956
+
957
+ // Write activeChildIds Set as count + array of NumericUuids
958
+ index = writeNumber(serializedFloat, index, this.shardingState.activeChildIds.size);
959
+ for (const childId of this.shardingState.activeChildIds) {
960
+ index = writeNumericUuid(serializedUint, index, numericUuidFromStableId(childId));
961
+ }
962
+
963
+ // Write shardId (optional)
964
+ index = writeBoolean(
965
+ serializedFloat,
966
+ index,
967
+ this.shardingState.shardId !== undefined,
968
+ );
969
+ if (this.shardingState.shardId !== undefined) {
970
+ index = writeNumericUuid(
971
+ serializedUint,
972
+ index,
973
+ numericUuidFromStableId(this.shardingState.shardId),
974
+ );
975
+ }
976
+ }
977
+ }
626
978
  }
627
979
 
628
980
  assert(index === totalSize, 0x75b /* Serialized size was incorrectly calculated. */);
@@ -640,41 +992,45 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
640
992
  params:
641
993
  | {
642
994
  serialized: SerializedIdCompressorWithOngoingSession;
995
+ requestedWriteVersion: SerializationVersion;
643
996
  logger?: TelemetryLoggerExt | undefined;
644
997
  newSessionId?: never;
645
998
  }
646
999
  | {
647
1000
  serialized: SerializedIdCompressorWithNoSession;
648
1001
  newSessionId: SessionId;
1002
+ requestedWriteVersion: SerializationVersion;
649
1003
  logger?: TelemetryLoggerExt | undefined;
650
1004
  },
651
1005
  ): IdCompressor {
652
- const { serialized, newSessionId, logger } = params;
1006
+ const { serialized, newSessionId: sessionId, logger, requestedWriteVersion } = params;
653
1007
  const buffer = stringToBuffer(serialized, "base64");
654
1008
  const index: Index = {
655
1009
  index: 0,
656
1010
  bufferFloat: new Float64Array(buffer),
657
1011
  bufferUint: new BigUint64Array(buffer),
658
1012
  };
659
- const version = readNumber(index);
660
- switch (version) {
661
- case 1: {
662
- throw new Error("IdCompressor version 1.0 is no longer supported.");
663
- }
664
- case 2: {
665
- return IdCompressor.deserialize2_0(index, newSessionId, logger);
1013
+ const serializedVersion = readNumber(index) as SerializationVersion;
1014
+ // If requested version is < the serialized version, we must write the version serialized
1015
+ // to avoid losing data.
1016
+ const writeVersion = Math.max(
1017
+ requestedWriteVersion,
1018
+ serializedVersion,
1019
+ ) as SerializationVersion;
1020
+
1021
+ switch (serializedVersion) {
1022
+ case 2:
1023
+ case 3: {
1024
+ break;
666
1025
  }
667
1026
  default: {
1027
+ if (serializedVersion === 1) {
1028
+ throw new Error("IdCompressor version 1.0 is no longer supported.");
1029
+ }
668
1030
  throw new Error("Unknown IdCompressor serialized version.");
669
1031
  }
670
1032
  }
671
- }
672
1033
 
673
- static deserialize2_0(
674
- index: Index,
675
- sessionId: SessionId | undefined,
676
- logger: TelemetryLoggerExt | undefined,
677
- ): IdCompressor {
678
1034
  const hasLocalState = readBoolean(index);
679
1035
  const sessionCount = readNumber(index);
680
1036
  const clusterCount = readNumber(index);
@@ -700,7 +1056,7 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
700
1056
  sessions.push([numeric, new Session(numeric)]);
701
1057
  }
702
1058
 
703
- const compressor = new IdCompressor(new Sessions(sessions), logger);
1059
+ const compressor = new IdCompressor(new Sessions(sessions), logger, writeVersion);
704
1060
 
705
1061
  // Clusters
706
1062
  let baseFinalId = 0;
@@ -727,6 +1083,38 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
727
1083
  for (let i = 0; i < normalizerCount; i++) {
728
1084
  compressor.normalizer.addLocalRange(readNumber(index), readNumber(index));
729
1085
  }
1086
+
1087
+ // Sharding state (version 3+)
1088
+ if (serializedVersion >= 3) {
1089
+ const hasShardingState = readBoolean(index);
1090
+ if (hasShardingState) {
1091
+ const currentStride = readNumber(index);
1092
+ const originalStride = readNumber(index);
1093
+
1094
+ // Read activeChildIds Set
1095
+ const activeChildCount = readNumber(index);
1096
+ const activeChildIds = new Set<SessionId>();
1097
+ for (let i = 0; i < activeChildCount; i++) {
1098
+ const childIdNumeric = readNumericUuid(index);
1099
+ // Safe cast: these were serialized as SessionIds
1100
+ activeChildIds.add(stableIdFromNumericUuid(childIdNumeric) as SessionId);
1101
+ }
1102
+
1103
+ // Read shardId (optional)
1104
+ const hasShardId = readBoolean(index);
1105
+ const shardId: SessionId | undefined = hasShardId
1106
+ ? (stableIdFromNumericUuid(readNumericUuid(index)) as SessionId)
1107
+ : undefined;
1108
+
1109
+ // Build sharding state object, conditionally including shardId
1110
+ compressor.shardingState = {
1111
+ currentStride,
1112
+ originalStride,
1113
+ activeChildIds,
1114
+ ...(shardId !== undefined && { shardId }),
1115
+ };
1116
+ }
1117
+ }
730
1118
  }
731
1119
 
732
1120
  assert(
@@ -747,6 +1135,34 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
747
1135
  ) {
748
1136
  return false;
749
1137
  }
1138
+ // Shard ownership affects future local ID allocation, even when the generated IDs match.
1139
+ // It is not part of finalized state, so ignore it when comparing only that state.
1140
+ if (includeLocalState) {
1141
+ const state = this.shardingState;
1142
+ const otherState = other.shardingState;
1143
+ if (state === undefined || otherState === undefined) {
1144
+ // Two unsharded compressors match here; a sharded and an unsharded compressor do not.
1145
+ if (state !== otherState) {
1146
+ return false;
1147
+ }
1148
+ } else {
1149
+ if (
1150
+ state.currentStride !== otherState.currentStride ||
1151
+ state.originalStride !== otherState.originalStride ||
1152
+ state.shardId !== otherState.shardId ||
1153
+ state.activeChildIds.size !== otherState.activeChildIds.size
1154
+ ) {
1155
+ return false;
1156
+ }
1157
+ // Child ownership is an unordered set. Equal sizes and membership imply equal sets,
1158
+ // regardless of insertion order or whether the state was deserialized.
1159
+ for (const childId of state.activeChildIds) {
1160
+ if (!otherState.activeChildIds.has(childId)) {
1161
+ return false;
1162
+ }
1163
+ }
1164
+ }
1165
+ }
750
1166
  return (
751
1167
  this.sessions.equals(other.sessions, includeLocalState) &&
752
1168
  this.finalSpace.equals(other.finalSpace)
@@ -756,73 +1172,94 @@ export class IdCompressor implements IIdCompressor, IIdCompressorCore {
756
1172
 
757
1173
  /**
758
1174
  * Create a new {@link IIdCompressor}.
759
- *
760
- * @legacy @beta
1175
+ * @param writeVersion - The version the compressor will write when serializing. Use SerializationVersion.V2 for base format, or SerializationVersion.V3 for sharding support.
1176
+ * @param logger - Optional telemetry logger.
1177
+ * @internal
761
1178
  */
762
- export function createIdCompressor(logger?: ITelemetryBaseLogger): IIdCompressor;
1179
+ export function createIdCompressor(
1180
+ writeVersion: SerializationVersion,
1181
+ logger?: ITelemetryBaseLogger,
1182
+ ): IIdCompressor & IIdCompressorCore;
763
1183
  /**
764
1184
  * Create a new {@link IIdCompressor}.
765
1185
  * @param sessionId - The seed ID for the compressor.
766
- *
767
- * @legacy @beta
1186
+ * @param writeVersion - The version the compressor will write when serializing. Use SerializationVersion.V2 for base format, or SerializationVersion.V3 for sharding support.
1187
+ * @param logger - Optional telemetry logger.
1188
+ * @internal
768
1189
  */
769
1190
  export function createIdCompressor(
770
1191
  sessionId: SessionId,
1192
+ writeVersion: SerializationVersion,
771
1193
  logger?: ITelemetryBaseLogger,
772
- ): IIdCompressor;
1194
+ ): IIdCompressor & IIdCompressorCore;
773
1195
  export function createIdCompressor(
774
- sessionIdOrLogger?: SessionId | ITelemetryBaseLogger,
1196
+ sessionIdOrDocumentVersion: SessionId | SerializationVersion,
1197
+ writeVersionOrLogger?: SerializationVersion | ITelemetryBaseLogger,
775
1198
  loggerOrUndefined?: ITelemetryBaseLogger,
776
1199
  ): IIdCompressor & IIdCompressorCore {
777
1200
  let localSessionId: SessionId;
1201
+ let writeVersion: SerializationVersion;
778
1202
  let logger: ITelemetryBaseLogger | undefined;
779
- if (sessionIdOrLogger === undefined) {
780
- localSessionId = createSessionId();
1203
+
1204
+ if (typeof sessionIdOrDocumentVersion === "string") {
1205
+ // Called with sessionId, writeVersion, logger?
1206
+ localSessionId = sessionIdOrDocumentVersion;
1207
+ writeVersion = writeVersionOrLogger as SerializationVersion;
1208
+ logger = loggerOrUndefined;
781
1209
  } else {
782
- if (typeof sessionIdOrLogger === "string") {
783
- localSessionId = sessionIdOrLogger;
784
- logger = loggerOrUndefined;
785
- } else {
786
- localSessionId = createSessionId();
787
- logger = sessionIdOrLogger;
788
- }
1210
+ // Called with writeVersion, logger?
1211
+ localSessionId = createSessionId();
1212
+ writeVersion = sessionIdOrDocumentVersion;
1213
+ logger = writeVersionOrLogger as ITelemetryBaseLogger | undefined;
789
1214
  }
1215
+
790
1216
  const compressor = new IdCompressor(
791
1217
  localSessionId,
792
1218
  logger === undefined ? undefined : createChildLogger({ logger }),
1219
+ writeVersion,
793
1220
  );
794
1221
  return compressor;
795
1222
  }
796
1223
 
797
1224
  /**
798
1225
  * Deserializes the supplied state into an ID compressor.
799
- *
800
- * @legacy @beta
1226
+ * @param serialized - The serialized compressor state with an ongoing session.
1227
+ * @param writeVersion - The version the compressor will write when serializing. This determines what features are available (e.g., V3 enables sharding support).
1228
+ * @param logger - Optional telemetry logger.
1229
+ * @internal
801
1230
  */
802
1231
  export function deserializeIdCompressor(
803
1232
  serialized: SerializedIdCompressorWithOngoingSession,
1233
+ writeVersion: SerializationVersion,
804
1234
  logger?: ITelemetryLoggerExt,
805
- ): IIdCompressor;
1235
+ ): IIdCompressor & IIdCompressorCore;
806
1236
  /**
807
1237
  * Deserializes the supplied state into an ID compressor.
808
- *
809
- * @legacy @beta
1238
+ * @param serialized - The serialized compressor state without a session.
1239
+ * @param newSessionId - The session ID to use for the deserialized compressor.
1240
+ * @param writeVersion - The version the compressor will write when serializing. This determines what features are available (e.g., V3 enables sharding support).
1241
+ * @param logger - Optional telemetry logger.
1242
+ * @internal
810
1243
  */
811
1244
  export function deserializeIdCompressor(
812
1245
  serialized: SerializedIdCompressorWithNoSession,
813
1246
  newSessionId: SessionId,
1247
+ writeVersion: SerializationVersion,
814
1248
  logger?: ITelemetryLoggerExt,
815
- ): IIdCompressor;
1249
+ ): IIdCompressor & IIdCompressorCore;
816
1250
  export function deserializeIdCompressor(
817
1251
  serialized: SerializedIdCompressor | SerializedIdCompressorWithNoSession,
818
- sessionIdOrLogger: SessionId | ITelemetryLoggerExt | undefined,
1252
+ sessionIdOrDocumentVersion: SessionId | SerializationVersion,
1253
+ writeVersionOrLogger?: SerializationVersion | ITelemetryLoggerExt,
819
1254
  loggerOrUndefined?: ITelemetryLoggerExt,
820
1255
  ): IIdCompressor & IIdCompressorCore {
821
- if (typeof sessionIdOrLogger === "string") {
1256
+ if (typeof sessionIdOrDocumentVersion === "string") {
1257
+ // Called with (serialized, sessionId, writeVersion, logger?)
822
1258
  return IdCompressor.deserialize({
823
1259
  serialized: serialized as SerializedIdCompressorWithNoSession,
824
1260
  logger: extractTelemetryLoggerExt<{ PossiblyUndefined: true }>(loggerOrUndefined),
825
- newSessionId: sessionIdOrLogger,
1261
+ newSessionId: sessionIdOrDocumentVersion,
1262
+ requestedWriteVersion: writeVersionOrLogger as SerializationVersion,
826
1263
  });
827
1264
  }
828
1265
 
@@ -830,18 +1267,53 @@ export function deserializeIdCompressor(
830
1267
  loggerOrUndefined === undefined,
831
1268
  0xc2d /* logger would be in sessionIdOrLogger in this codepath */,
832
1269
  );
1270
+ // Called with (serialized, writeVersion, logger?)
833
1271
  return IdCompressor.deserialize({
834
1272
  serialized: serialized as SerializedIdCompressorWithOngoingSession,
835
- logger: extractTelemetryLoggerExt<{ PossiblyUndefined: true }>(sessionIdOrLogger),
1273
+ logger: extractTelemetryLoggerExt<{ PossiblyUndefined: true }>(
1274
+ writeVersionOrLogger as ITelemetryLoggerExt | undefined,
1275
+ ),
1276
+ requestedWriteVersion: sessionIdOrDocumentVersion,
836
1277
  });
837
1278
  }
838
1279
 
1280
+ /**
1281
+ * Throws an error indicating the compressor has been disposed.
1282
+ */
1283
+ const throwDisposed = (): never => {
1284
+ throw new Error(
1285
+ "This compressor has been disposed via disposeShard() and can no longer be used.",
1286
+ );
1287
+ };
1288
+
1289
+ /**
1290
+ * Makes an IdCompressor instance unusable by replacing all its methods with functions that throw.
1291
+ * This is called once when disposeShard() is invoked to make the compressor permanently unusable.
1292
+ * @param compressor - The compressor instance to make unusable
1293
+ */
1294
+ function makeCompressorUnusable(compressor: IdCompressor): void {
1295
+ // Get all method names from the prototype (where class methods live)
1296
+ const proto = Object.getPrototypeOf(compressor) as object;
1297
+ for (const key of Reflect.ownKeys(proto)) {
1298
+ if (key !== "constructor" && typeof Reflect.get(proto, key) === "function") {
1299
+ // Shadow the method only on this instance so other compressors remain usable.
1300
+ // Omitting `enumerable` keeps new overrides non-enumerable, like class methods.
1301
+ // Reflection supports string and symbol keys without adding an index signature.
1302
+ Object.defineProperty(compressor, key, {
1303
+ value: throwDisposed,
1304
+ configurable: true,
1305
+ writable: true,
1306
+ });
1307
+ }
1308
+ }
1309
+ }
1310
+
839
1311
  /**
840
1312
  * Serializes an ID compressor.
841
1313
  * @param compressor - The compressor to serialize.
842
1314
  * @param withSession - If true, the serialized state will include local session
843
1315
  * state (for stashing). If false, only finalized state is included (for summaries).
844
- * @legacy @beta
1316
+ * @internal
845
1317
  */
846
1318
  export function serializeIdCompressor(
847
1319
  compressor: IIdCompressor,
@@ -852,7 +1324,7 @@ export function serializeIdCompressor(
852
1324
  * @param compressor - The compressor to serialize.
853
1325
  * @param withSession - If true, the serialized state will include local session
854
1326
  * state (for stashing). If false, only finalized state is included (for summaries).
855
- * @legacy @beta
1327
+ * @internal
856
1328
  */
857
1329
  export function serializeIdCompressor(
858
1330
  compressor: IIdCompressor,