@fluidframework/map 2.51.0 → 2.52.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.
package/src/mapKernel.ts CHANGED
@@ -5,22 +5,14 @@
5
5
 
6
6
  import type { TypedEventEmitter } from "@fluid-internal/client-utils";
7
7
  import type { IFluidHandle } from "@fluidframework/core-interfaces";
8
- import {
9
- assert,
10
- DoublyLinkedList,
11
- type ListNode,
12
- unreachableCase,
13
- } from "@fluidframework/core-utils/internal";
8
+ import { assert, unreachableCase } from "@fluidframework/core-utils/internal";
14
9
  import type { IFluidSerializer } from "@fluidframework/shared-object-base/internal";
15
10
  import { ValueType } from "@fluidframework/shared-object-base/internal";
16
11
 
17
12
  import type { ISharedMapEvents } from "./interfaces.js";
18
13
  import type {
19
- IMapClearLocalOpMetadata,
20
14
  IMapClearOperation,
21
15
  IMapDeleteOperation,
22
- IMapKeyAddLocalOpMetadata,
23
- IMapKeyEditLocalOpMetadata,
24
16
  IMapSetOperation,
25
17
  // eslint-disable-next-line import/no-deprecated
26
18
  ISerializableValue,
@@ -46,7 +38,7 @@ interface IMapMessageHandler {
46
38
  process(
47
39
  op: IMapOperation,
48
40
  local: boolean,
49
- localOpMetadata: ListNode<MapLocalOpMetadata> | undefined,
41
+ localOpMetadata: PendingLocalOpMetadata | undefined,
50
42
  ): void;
51
43
 
52
44
  /**
@@ -54,18 +46,13 @@ interface IMapMessageHandler {
54
46
  * @param op - The map operation to resubmit
55
47
  * @param localOpMetadata - The metadata that was originally submitted with the message.
56
48
  */
57
- resubmit(op: IMapOperation, localOpMetadata: ListNode<MapLocalOpMetadata>): void;
49
+ resubmit(op: IMapOperation, localOpMetadata: PendingLocalOpMetadata): void;
58
50
  }
59
51
 
60
52
  /**
61
- * Map key operations are one of several types.
62
- */
63
- export type IMapKeyOperation = IMapSetOperation | IMapDeleteOperation;
64
-
65
- /**
66
- * Description of a map delta operation
53
+ * Union of all possible map operations.
67
54
  */
68
- export type IMapOperation = IMapKeyOperation | IMapClearOperation;
55
+ export type IMapOperation = IMapSetOperation | IMapDeleteOperation | IMapClearOperation;
69
56
 
70
57
  /**
71
58
  * Defines the in-memory object structure to be used for the conversion to/from serialized.
@@ -83,53 +70,61 @@ export type IMapDataObjectSerializable = Record<string, ISerializableValue>;
83
70
  */
84
71
  export type IMapDataObjectSerialized = Record<string, ISerializedValue>;
85
72
 
86
- type MapKeyLocalOpMetadata = IMapKeyEditLocalOpMetadata | IMapKeyAddLocalOpMetadata;
87
- type MapLocalOpMetadata = IMapClearLocalOpMetadata | MapKeyLocalOpMetadata;
88
-
89
- /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
90
-
91
- function isMapKeyLocalOpMetadata(metadata: any): metadata is MapKeyLocalOpMetadata {
92
- return (
93
- metadata !== undefined &&
94
- typeof metadata.pendingMessageId === "number" &&
95
- (metadata.type === "add" || metadata.type === "edit")
96
- );
73
+ interface PendingKeySet {
74
+ type: "set";
75
+ value: ILocalValue;
97
76
  }
98
77
 
99
- function isClearLocalOpMetadata(metadata: any): metadata is IMapClearLocalOpMetadata {
100
- return (
101
- metadata !== undefined &&
102
- metadata.type === "clear" &&
103
- typeof metadata.pendingMessageId === "number"
104
- );
78
+ interface PendingKeyDelete {
79
+ type: "delete";
80
+ key: string;
105
81
  }
106
82
 
107
- /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
108
-
109
- function createClearLocalOpMetadata(
110
- op: IMapClearOperation,
111
- pendingClearMessageId: number,
112
- previousMap?: Map<string, ILocalValue>,
113
- ): IMapClearLocalOpMetadata {
114
- const localMetadata: IMapClearLocalOpMetadata = {
115
- type: "clear",
116
- pendingMessageId: pendingClearMessageId,
117
- previousMap,
118
- };
119
- return localMetadata;
83
+ interface PendingClear {
84
+ type: "clear";
120
85
  }
121
86
 
122
- function createKeyLocalOpMetadata(
123
- op: IMapKeyOperation,
124
- pendingMessageId: number,
125
- previousValue?: ILocalValue,
126
- ): MapKeyLocalOpMetadata {
127
- const localMetadata: MapKeyLocalOpMetadata = previousValue
128
- ? { type: "edit", pendingMessageId, previousValue }
129
- : { type: "add", pendingMessageId };
130
- return localMetadata;
87
+ interface PendingKeyLifetime {
88
+ type: "lifetime";
89
+ key: string;
90
+ /**
91
+ * A non-empty array of pending key sets that occurred during this lifetime. If the list
92
+ * becomes empty (e.g. during processing or rollback), the lifetime no longer exists and
93
+ * must be removed from the pending data.
94
+ */
95
+ keySets: PendingKeySet[];
131
96
  }
132
97
 
98
+ /**
99
+ * A member of the pendingData array, which tracks outstanding changes and can be used to
100
+ * compute optimistic values. Local sets are aggregated into lifetimes.
101
+ */
102
+ type PendingDataEntry = PendingKeyLifetime | PendingKeyDelete | PendingClear;
103
+ /**
104
+ * An individual outstanding change, which will also be found in the pendingData array
105
+ * (though the PendingKeySets will be contained within a PendingKeyLifetime there).
106
+ */
107
+ type PendingLocalOpMetadata = PendingKeySet | PendingKeyDelete | PendingClear;
108
+
109
+ /**
110
+ * Rough polyfill for Array.findLastIndex until we target ES2023 or greater.
111
+ */
112
+ const findLastIndex = <T>(array: T[], callbackFn: (value: T) => boolean): number => {
113
+ for (let i = array.length - 1; i >= 0; i--) {
114
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
115
+ if (callbackFn(array[i]!)) {
116
+ return i;
117
+ }
118
+ }
119
+ return -1;
120
+ };
121
+
122
+ /**
123
+ * Rough polyfill for Array.findLast until we target ES2023 or greater.
124
+ */
125
+ const findLast = <T>(array: T[], callbackFn: (value: T) => boolean): T | undefined =>
126
+ array[findLastIndex(array, callbackFn)];
127
+
133
128
  /**
134
129
  * A SharedMap is a map-like distributed data structure.
135
130
  */
@@ -138,7 +133,8 @@ export class MapKernel {
138
133
  * The number of key/value pairs stored in the map.
139
134
  */
140
135
  public get size(): number {
141
- return this.data.size;
136
+ const iterableItems = [...this.internalIterator()];
137
+ return iterableItems.length;
142
138
  }
143
139
 
144
140
  /**
@@ -147,30 +143,18 @@ export class MapKernel {
147
143
  private readonly messageHandlers: ReadonlyMap<string, IMapMessageHandler> = new Map();
148
144
 
149
145
  /**
150
- * The in-memory data the map is storing.
146
+ * The data the map is storing, but only including sequenced values (no local pending
147
+ * modifications are included).
151
148
  */
152
- private readonly data = new Map<string, ILocalValue>();
153
-
149
+ private readonly sequencedData = new Map<string, ILocalValue>();
154
150
  /**
155
- * Keys that have been modified locally but not yet ack'd from the server.
156
- */
157
- private readonly pendingKeys = new Map<string, number[]>();
158
-
159
- /**
160
- * This is used to assign a unique id to every outgoing operation and helps in tracking unack'd ops.
161
- */
162
- private nextPendingMessageId: number = 0;
163
-
164
- /**
165
- * The pending metadata for any local operations that have not yet been ack'd from the server, in order.
166
- */
167
- private readonly pendingMapLocalOpMetadata: DoublyLinkedList<MapLocalOpMetadata> =
168
- new DoublyLinkedList<MapLocalOpMetadata>();
169
-
170
- /**
171
- * The pending ids of any clears that have been performed locally but not yet ack'd from the server
151
+ * A data structure containing all local pending modifications, which is used in combination
152
+ * with the sequencedData to compute optimistic values.
153
+ *
154
+ * Pending sets are aggregated into "lifetimes", which permit correct relative iteration order
155
+ * even across remote operations and rollbacks.
172
156
  */
173
- private readonly pendingClearMessageIds: number[] = [];
157
+ private readonly pendingData: PendingDataEntry[] = [];
174
158
 
175
159
  /**
176
160
  * Create a new shared map kernel.
@@ -192,27 +176,112 @@ export class MapKernel {
192
176
  }
193
177
 
194
178
  /**
195
- * Get an iterator over the keys in this map.
196
- * @returns The iterator
197
- */
198
- public keys(): IterableIterator<string> {
199
- return this.data.keys();
200
- }
179
+ * Get an iterator over the optimistically observable ILocalValue entries in the map. For example, excluding
180
+ * sequenced entries that have pending deletes/clears.
181
+ *
182
+ * @remarks
183
+ * There is no perfect solution here, particularly when the iterator is retained over time and the map is
184
+ * modified or new ack's are received. The pendingData portion of the iteration is the most susceptible to
185
+ * this problem. The implementation prioritizes (in roughly this order):
186
+ * 1. Correct immediate iteration (i.e. when the map is not modified before iteration completes)
187
+ * 2. Consistent iteration order before/after sequencing of pending ops; acks don't change order
188
+ * 3. Consistent iteration order between synchronized clients, even if they each modified the map concurrently
189
+ * 4. Remaining as close as possible to the native Map iterator behavior, e.g. live-ish view rather than snapshot
190
+ *
191
+ * For this reason, it's important not to internally snapshot the output of the iterator for any purpose that
192
+ * does not immediately (synchronously) consume that output and dispose of it.
193
+ */
194
+ private readonly internalIterator = (): IterableIterator<[string, ILocalValue]> => {
195
+ // We perform iteration in two steps - first by iterating over members of the sequenced data that are not
196
+ // optimistically deleted or cleared, and then over the pending data lifetimes that have not subsequently
197
+ // been deleted or cleared. In total, this give an ordering of members based on when they were initially
198
+ // added to the map (even if they were later modified), similar to the native Map.
199
+ const sequencedDataIterator = this.sequencedData.keys();
200
+ const pendingDataIterator = this.pendingData.values();
201
+ const next = (): IteratorResult<[string, ILocalValue]> => {
202
+ let nextSequencedKey = sequencedDataIterator.next();
203
+ while (!nextSequencedKey.done) {
204
+ const key = nextSequencedKey.value;
205
+ // If we have any pending deletes or clears, then we won't iterate to this key yet (if at all).
206
+ // Either it is optimistically deleted and will not be part of the iteration, or it was
207
+ // re-added later and we'll iterate to it when we get to the pending data.
208
+ if (
209
+ !this.pendingData.some(
210
+ (entry) =>
211
+ entry.type === "clear" || (entry.type === "delete" && entry.key === key),
212
+ )
213
+ ) {
214
+ const optimisticValue = this.getOptimisticLocalValue(key);
215
+ assert(
216
+ optimisticValue !== undefined,
217
+ 0xbf1 /* Should never iterate to a key with undefined optimisticValue */,
218
+ );
219
+ return { value: [key, optimisticValue], done: false };
220
+ }
221
+ nextSequencedKey = sequencedDataIterator.next();
222
+ }
223
+
224
+ let nextPending = pendingDataIterator.next();
225
+ while (!nextPending.done) {
226
+ const nextPendingEntry = nextPending.value;
227
+ // A lifetime entry may need to be iterated.
228
+ if (nextPendingEntry.type === "lifetime") {
229
+ const nextPendingEntryIndex = this.pendingData.indexOf(nextPendingEntry);
230
+ const mostRecentDeleteOrClearIndex = findLastIndex(
231
+ this.pendingData,
232
+ (entry) =>
233
+ entry.type === "clear" ||
234
+ (entry.type === "delete" && entry.key === nextPendingEntry.key),
235
+ );
236
+ // Only iterate the pending entry now if it hasn't been deleted or cleared.
237
+ if (nextPendingEntryIndex > mostRecentDeleteOrClearIndex) {
238
+ const latestPendingValue =
239
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
240
+ nextPendingEntry.keySets[nextPendingEntry.keySets.length - 1]!;
241
+ // Skip iterating if we would have would have already iterated it as part of the sequenced data.
242
+ // This is not a perfect check in the case the map has changed since the iterator was created
243
+ // (e.g. if a remote client added the same key in the meantime).
244
+ if (
245
+ !this.sequencedData.has(nextPendingEntry.key) ||
246
+ mostRecentDeleteOrClearIndex !== -1
247
+ ) {
248
+ return { value: [nextPendingEntry.key, latestPendingValue.value], done: false };
249
+ }
250
+ }
251
+ }
252
+ nextPending = pendingDataIterator.next();
253
+ }
254
+
255
+ return { value: undefined, done: true };
256
+ };
257
+
258
+ const iterator = {
259
+ next,
260
+ [Symbol.iterator](): IterableIterator<[string, ILocalValue]> {
261
+ return this;
262
+ },
263
+ };
264
+ return iterator;
265
+ };
201
266
 
202
267
  /**
203
268
  * Get an iterator over the entries in this map.
204
269
  * @returns The iterator
205
270
  */
206
271
  public entries(): IterableIterator<[string, unknown]> {
207
- const localEntriesIterator = this.data.entries();
272
+ const internalIterator = this.internalIterator();
273
+ const next = (): IteratorResult<[string, unknown]> => {
274
+ const nextResult = internalIterator.next();
275
+ if (nextResult.done) {
276
+ return { value: undefined, done: true };
277
+ }
278
+ // Unpack the stored value
279
+ const [key, localValue] = nextResult.value;
280
+ return { value: [key, localValue.value], done: false };
281
+ };
282
+
208
283
  const iterator = {
209
- next(): IteratorResult<[string, unknown]> {
210
- const nextVal = localEntriesIterator.next();
211
- return nextVal.done
212
- ? { value: undefined, done: true }
213
- : // Unpack the stored value
214
- { value: [nextVal.value[0], nextVal.value[1].value], done: false };
215
- },
284
+ next,
216
285
  [Symbol.iterator](): IterableIterator<[string, unknown]> {
217
286
  return this;
218
287
  },
@@ -220,20 +289,45 @@ export class MapKernel {
220
289
  return iterator;
221
290
  }
222
291
 
292
+ /**
293
+ * Get an iterator over the keys in this map.
294
+ * @returns The iterator
295
+ */
296
+ public keys(): IterableIterator<string> {
297
+ const internalIterator = this.internalIterator();
298
+ const next = (): IteratorResult<string> => {
299
+ const nextResult = internalIterator.next();
300
+ if (nextResult.done) {
301
+ return { value: undefined, done: true };
302
+ }
303
+ const [key] = nextResult.value;
304
+ return { value: key, done: false };
305
+ };
306
+ const iterator = {
307
+ next,
308
+ [Symbol.iterator](): IterableIterator<string> {
309
+ return this;
310
+ },
311
+ };
312
+ return iterator;
313
+ }
314
+
223
315
  /**
224
316
  * Get an iterator over the values in this map.
225
317
  * @returns The iterator
226
318
  */
227
319
  public values(): IterableIterator<unknown> {
228
- const localValuesIterator = this.data.values();
320
+ const internalIterator = this.internalIterator();
321
+ const next = (): IteratorResult<unknown> => {
322
+ const nextResult = internalIterator.next();
323
+ if (nextResult.done) {
324
+ return { value: undefined, done: true };
325
+ }
326
+ const [, localValue] = nextResult.value;
327
+ return { value: localValue.value, done: false };
328
+ };
229
329
  const iterator = {
230
- next(): IteratorResult<unknown> {
231
- const nextVal = localValuesIterator.next();
232
- return nextVal.done
233
- ? { value: undefined, done: true }
234
- : // Unpack the stored value
235
- { value: nextVal.value.value, done: false };
236
- },
330
+ next,
237
331
  [Symbol.iterator](): IterableIterator<unknown> {
238
332
  return this;
239
333
  },
@@ -256,17 +350,44 @@ export class MapKernel {
256
350
  public forEach(
257
351
  callbackFn: (value: unknown, key: string, map: Map<string, unknown>) => void,
258
352
  ): void {
353
+ // It would be better to iterate over the data without a temp map. However, we don't have a valid
354
+ // map to pass for the third argument here (really, it should probably should be a reference to the
355
+ // SharedMap and not the MapKernel).
356
+ const tempMap = new Map(this.internalIterator());
259
357
  // eslint-disable-next-line unicorn/no-array-for-each
260
- this.data.forEach((localValue, key, m) => {
358
+ tempMap.forEach((localValue, key, m) => {
261
359
  callbackFn(localValue.value, key, m);
262
360
  });
263
361
  }
264
362
 
363
+ /**
364
+ * Compute the optimistic local value for a given key. This combines the sequenced data with
365
+ * any pending changes that have not yet been sequenced.
366
+ */
367
+ private readonly getOptimisticLocalValue = (key: string): ILocalValue | undefined => {
368
+ const latestPendingEntry = findLast(
369
+ this.pendingData,
370
+ (entry) => entry.type === "clear" || entry.key === key,
371
+ );
372
+
373
+ if (latestPendingEntry === undefined) {
374
+ return this.sequencedData.get(key);
375
+ } else if (latestPendingEntry.type === "lifetime") {
376
+ const latestPendingSet =
377
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
378
+ latestPendingEntry.keySets[latestPendingEntry.keySets.length - 1]!;
379
+ return latestPendingSet.value;
380
+ } else {
381
+ // Delete or clear
382
+ return undefined;
383
+ }
384
+ };
385
+
265
386
  /**
266
387
  * {@inheritDoc ISharedMap.get}
267
388
  */
268
389
  public get<T = unknown>(key: string): T | undefined {
269
- const localValue = this.data.get(key);
390
+ const localValue = this.getOptimisticLocalValue(key);
270
391
  return localValue === undefined ? undefined : (localValue.value as T);
271
392
  }
272
393
 
@@ -276,7 +397,7 @@ export class MapKernel {
276
397
  * @returns True if the key exists, false otherwise
277
398
  */
278
399
  public has(key: string): boolean {
279
- return this.data.has(key);
400
+ return this.getOptimisticLocalValue(key) !== undefined;
280
401
  }
281
402
 
282
403
  /**
@@ -288,20 +409,55 @@ export class MapKernel {
288
409
  throw new Error("Undefined and null keys are not supported");
289
410
  }
290
411
 
291
- // Set the value locally.
292
- const previousValue = this.setCore(key, { value }, true);
412
+ const localValue: ILocalValue = { value };
413
+ const previousOptimisticLocalValue = this.getOptimisticLocalValue(key);
293
414
 
294
415
  // If we are not attached, don't submit the op.
295
416
  if (!this.isAttached()) {
417
+ this.sequencedData.set(key, localValue);
418
+ this.eventEmitter.emit(
419
+ "valueChanged",
420
+ { key, previousValue: previousOptimisticLocalValue?.value },
421
+ true,
422
+ this.eventEmitter,
423
+ );
296
424
  return;
297
425
  }
298
426
 
427
+ // A new pending key lifetime is created if:
428
+ // 1. There isn't any pending entry for the key yet
429
+ // 2. The most recent pending entry for the key was a deletion (as this terminates the prior lifetime)
430
+ // 3. A clear was sent after the last pending entry for the key (which also terminates the prior lifetime)
431
+ let latestPendingEntry = findLast(
432
+ this.pendingData,
433
+ (entry) => entry.type === "clear" || entry.key === key,
434
+ );
435
+ if (
436
+ latestPendingEntry === undefined ||
437
+ latestPendingEntry.type === "delete" ||
438
+ latestPendingEntry.type === "clear"
439
+ ) {
440
+ latestPendingEntry = { type: "lifetime", key, keySets: [] };
441
+ this.pendingData.push(latestPendingEntry);
442
+ }
443
+ const pendingKeySet: PendingKeySet = {
444
+ type: "set",
445
+ value: localValue,
446
+ };
447
+ latestPendingEntry.keySets.push(pendingKeySet);
448
+
299
449
  const op: IMapSetOperation = {
300
450
  key,
301
451
  type: "set",
302
- value: { type: ValueType[ValueType.Plain], value },
452
+ value: { type: ValueType[ValueType.Plain], value: localValue.value },
303
453
  };
304
- this.submitMapKeyMessage(op, previousValue);
454
+ this.submitMessage(op, pendingKeySet);
455
+ this.eventEmitter.emit(
456
+ "valueChanged",
457
+ { key, previousValue: previousOptimisticLocalValue?.value },
458
+ true,
459
+ this.eventEmitter,
460
+ );
305
461
  }
306
462
 
307
463
  /**
@@ -310,44 +466,68 @@ export class MapKernel {
310
466
  * @returns True if the key existed and was deleted, false if it did not exist
311
467
  */
312
468
  public delete(key: string): boolean {
313
- // Delete the key locally first.
314
- const previousValue = this.deleteCore(key, true);
469
+ const previousOptimisticLocalValue = this.getOptimisticLocalValue(key);
315
470
 
316
- // If we are not attached, don't submit the op.
317
471
  if (!this.isAttached()) {
318
- return previousValue !== undefined;
472
+ const successfullyRemoved = this.sequencedData.delete(key);
473
+ // Only emit if we actually deleted something.
474
+ if (previousOptimisticLocalValue !== undefined) {
475
+ this.eventEmitter.emit(
476
+ "valueChanged",
477
+ { key, previousValue: previousOptimisticLocalValue.value },
478
+ true,
479
+ this.eventEmitter,
480
+ );
481
+ }
482
+ return successfullyRemoved;
319
483
  }
320
484
 
485
+ const pendingKeyDelete: PendingKeyDelete = {
486
+ type: "delete",
487
+ key,
488
+ };
489
+ this.pendingData.push(pendingKeyDelete);
490
+
321
491
  const op: IMapDeleteOperation = {
322
492
  key,
323
493
  type: "delete",
324
494
  };
325
- this.submitMapKeyMessage(op, previousValue);
495
+ this.submitMessage(op, pendingKeyDelete);
496
+ // Only emit if we locally believe we deleted something. Otherwise we still send the op
497
+ // (permitting speculative deletion even if we don't see anything locally) but don't emit
498
+ // a valueChanged since we in fact did not locally observe a value change.
499
+ if (previousOptimisticLocalValue !== undefined) {
500
+ this.eventEmitter.emit(
501
+ "valueChanged",
502
+ { key, previousValue: previousOptimisticLocalValue.value },
503
+ true,
504
+ this.eventEmitter,
505
+ );
506
+ }
326
507
 
327
- return previousValue !== undefined;
508
+ return true;
328
509
  }
329
510
 
330
511
  /**
331
512
  * Clear all data from the map.
332
513
  */
333
514
  public clear(): void {
334
- const copy = this.isAttached() ? new Map<string, ILocalValue>(this.data) : undefined;
335
-
336
- // Clear the data locally first.
337
- this.clearCore(true);
338
-
339
- // Clear the pendingKeys immediately, the local unack'd operations are aborted
340
- this.pendingKeys.clear();
341
-
342
- // If we are not attached, don't submit the op.
343
515
  if (!this.isAttached()) {
516
+ this.sequencedData.clear();
517
+ this.eventEmitter.emit("clear", true, this.eventEmitter);
344
518
  return;
345
519
  }
346
520
 
521
+ const pendingClear: PendingClear = {
522
+ type: "clear",
523
+ };
524
+ this.pendingData.push(pendingClear);
525
+
347
526
  const op: IMapClearOperation = {
348
527
  type: "clear",
349
528
  };
350
- this.submitMapClearMessage(op, copy);
529
+ this.submitMessage(op, pendingClear);
530
+ this.eventEmitter.emit("clear", true, this.eventEmitter);
351
531
  }
352
532
 
353
533
  /**
@@ -357,7 +537,7 @@ export class MapKernel {
357
537
  */
358
538
  public getSerializedStorage(serializer: IFluidSerializer): IMapDataObjectSerialized {
359
539
  const serializedMapData: IMapDataObjectSerialized = {};
360
- for (const [key, localValue] of this.data.entries()) {
540
+ for (const [key, localValue] of this.sequencedData.entries()) {
361
541
  serializedMapData[key] = serializeValue(localValue.value, serializer, this.handle);
362
542
  }
363
543
  return serializedMapData;
@@ -372,7 +552,7 @@ export class MapKernel {
372
552
  this.serializer.decode(json) as IMapDataObjectSerializable,
373
553
  )) {
374
554
  migrateIfSharedSerializable(serializable, this.serializer, this.handle);
375
- this.data.set(key, { value: serializable.value });
555
+ this.sequencedData.set(key, { value: serializable.value });
376
556
  }
377
557
  }
378
558
 
@@ -389,7 +569,7 @@ export class MapKernel {
389
569
  if (handler === undefined) {
390
570
  return false;
391
571
  }
392
- handler.resubmit(op, localOpMetadata as ListNode<MapLocalOpMetadata>);
572
+ handler.resubmit(op, localOpMetadata as PendingLocalOpMetadata);
393
573
  return true;
394
574
  }
395
575
 
@@ -438,7 +618,7 @@ export class MapKernel {
438
618
  if (handler === undefined) {
439
619
  return false;
440
620
  }
441
- handler.process(op, local, localOpMetadata as ListNode<MapLocalOpMetadata> | undefined);
621
+ handler.process(op, local, localOpMetadata as PendingLocalOpMetadata | undefined);
442
622
  return true;
443
623
  }
444
624
 
@@ -449,167 +629,66 @@ export class MapKernel {
449
629
  */
450
630
  public rollback(op: unknown, localOpMetadata: unknown): void {
451
631
  const mapOp: IMapOperation = op as IMapOperation;
452
- const listNodeLocalOpMetadata = localOpMetadata as ListNode<MapLocalOpMetadata>;
453
- const removedLocalOpMetadata = this.pendingMapLocalOpMetadata.pop();
454
- assert(
455
- removedLocalOpMetadata !== undefined &&
456
- removedLocalOpMetadata === listNodeLocalOpMetadata,
457
- 0xbcb /* Rolling back unexpected op */,
458
- );
459
-
460
- if (mapOp.type === "clear" && listNodeLocalOpMetadata.data.type === "clear") {
461
- if (listNodeLocalOpMetadata.data.previousMap === undefined) {
462
- throw new Error("Cannot rollback without previous map");
463
- }
464
- for (const [key, localValue] of listNodeLocalOpMetadata.data.previousMap.entries()) {
465
- this.setCore(key, localValue, true);
466
- }
467
-
468
- const lastPendingClearId = this.pendingClearMessageIds.pop();
469
- if (
470
- lastPendingClearId === undefined ||
471
- lastPendingClearId !== listNodeLocalOpMetadata.data.pendingMessageId
472
- ) {
473
- throw new Error("Rollback op does match last clear");
474
- }
475
- } else if (mapOp.type === "delete" || mapOp.type === "set") {
476
- if (listNodeLocalOpMetadata.data.type === "add") {
477
- this.deleteCore(mapOp.key, true);
478
- } else if (
479
- listNodeLocalOpMetadata.data.type === "edit" &&
480
- listNodeLocalOpMetadata.data.previousValue !== undefined
481
- ) {
482
- this.setCore(mapOp.key, listNodeLocalOpMetadata.data.previousValue, true);
483
- } else {
484
- throw new Error("Cannot rollback without previous value");
485
- }
486
-
487
- const pendingMessageIds = this.pendingKeys.get(mapOp.key);
488
- const lastPendingMessageId = pendingMessageIds?.pop();
489
- if (
490
- !pendingMessageIds ||
491
- lastPendingMessageId !== listNodeLocalOpMetadata.data.pendingMessageId
492
- ) {
493
- throw new Error("Rollback op does not match last pending");
494
- }
495
- if (pendingMessageIds.length === 0) {
496
- this.pendingKeys.delete(mapOp.key);
497
- }
498
- } else {
499
- throw new Error("Unsupported op for rollback");
500
- }
501
- }
502
-
503
- /**
504
- * Set implementation used for both locally sourced sets as well as incoming remote sets.
505
- * @param key - The key being set
506
- * @param value - The value being set
507
- * @param local - Whether the message originated from the local client
508
- * @returns Previous local value of the key, if any
509
- */
510
- private setCore(key: string, value: ILocalValue, local: boolean): ILocalValue | undefined {
511
- const previousLocalValue = this.data.get(key);
512
- const previousValue: unknown = previousLocalValue?.value;
513
- this.data.set(key, value);
514
- this.eventEmitter.emit("valueChanged", { key, previousValue }, local, this.eventEmitter);
515
- return previousLocalValue;
516
- }
517
-
518
- /**
519
- * Clear implementation used for both locally sourced clears as well as incoming remote clears.
520
- * @param local - Whether the message originated from the local client
521
- */
522
- private clearCore(local: boolean): void {
523
- this.data.clear();
524
- this.eventEmitter.emit("clear", local, this.eventEmitter);
525
- }
526
-
527
- /**
528
- * Delete implementation used for both locally sourced deletes as well as incoming remote deletes.
529
- * @param key - The key being deleted
530
- * @param local - Whether the message originated from the local client
531
- * @returns Previous local value of the key if it existed, undefined if it did not exist
532
- */
533
- private deleteCore(key: string, local: boolean): ILocalValue | undefined {
534
- const previousLocalValue = this.data.get(key);
535
- const previousValue: unknown = previousLocalValue?.value;
536
- const successfullyRemoved = this.data.delete(key);
537
- if (successfullyRemoved) {
538
- this.eventEmitter.emit("valueChanged", { key, previousValue }, local, this.eventEmitter);
539
- }
540
- return previousLocalValue;
541
- }
542
-
543
- /**
544
- * Clear all keys in memory in response to a remote clear, but retain keys we have modified but not yet been ack'd.
545
- */
546
- private clearExceptPendingKeys(): void {
547
- // Assuming the pendingKeys is small and the map is large
548
- // we will get the value for the pendingKeys and clear the map
549
- const temp = new Map<string, ILocalValue>();
550
- for (const key of this.pendingKeys.keys()) {
551
- // Verify if the most recent pending operation is a delete op, no need to retain it if so.
552
- // This ensures the map size remains consistent.
553
- if (this.data.has(key)) {
554
- temp.set(key, this.data.get(key) as ILocalValue);
555
- }
556
- }
557
- this.clearCore(false);
558
- for (const [key, value] of temp.entries()) {
559
- this.setCore(key, value, true);
560
- }
561
- }
562
-
563
- /**
564
- * If our local operations that have not yet been ack'd will eventually overwrite an incoming operation, we should
565
- * not process the incoming operation.
566
- * @param op - Operation to check
567
- * @param local - Whether the message originated from the local client
568
- * @param localOpMetadata - For local client messages, this is the metadata that was submitted with the message.
569
- * For messages from a remote client, this will be undefined.
570
- * @returns True if the operation should be processed, false otherwise
571
- */
572
- private needProcessKeyOperation(
573
- op: IMapKeyOperation,
574
- local: boolean,
575
- localOpMetadata: MapLocalOpMetadata | undefined,
576
- ): boolean {
577
- if (this.pendingClearMessageIds[0] !== undefined) {
578
- if (local) {
579
- assert(
580
- localOpMetadata !== undefined &&
581
- isMapKeyLocalOpMetadata(localOpMetadata) &&
582
- localOpMetadata.pendingMessageId < this.pendingClearMessageIds[0],
583
- 0x013 /* "Received out of order op when there is an unackd clear message" */,
632
+ const typedLocalOpMetadata = localOpMetadata as PendingLocalOpMetadata;
633
+ if (mapOp.type === "clear") {
634
+ // A pending clear will be last in the list, since it terminates all prior lifetimes.
635
+ const pendingClear = this.pendingData.pop();
636
+ assert(
637
+ pendingClear !== undefined &&
638
+ pendingClear.type === "clear" &&
639
+ pendingClear === typedLocalOpMetadata,
640
+ 0xbf2 /* Unexpected clear rollback */,
641
+ );
642
+ for (const [key] of this.internalIterator()) {
643
+ this.eventEmitter.emit(
644
+ "valueChanged",
645
+ { key, previousValue: undefined },
646
+ true,
647
+ this.eventEmitter,
584
648
  );
585
649
  }
586
- // If we have an unack'd clear, we can ignore all ops.
587
- return false;
588
- }
589
-
590
- const pendingKeyMessageIds = this.pendingKeys.get(op.key);
591
- if (pendingKeyMessageIds !== undefined) {
592
- // Found an unack'd op. Clear it from the map if the pendingMessageId in the map matches this message's
593
- // and don't process the op.
594
- if (local) {
595
- assert(
596
- localOpMetadata !== undefined && isMapKeyLocalOpMetadata(localOpMetadata),
597
- 0x014 /* pendingMessageId is missing from the local client's operation */,
598
- );
650
+ } else {
651
+ // A pending set/delete may not be last in the list, as the lifetimes' order is based on when
652
+ // they were created, not when they were last modified.
653
+ const pendingEntryIndex = findLastIndex(
654
+ this.pendingData,
655
+ (entry) => entry.type !== "clear" && entry.key === mapOp.key,
656
+ );
657
+ const pendingEntry = this.pendingData[pendingEntryIndex];
658
+ assert(
659
+ pendingEntry !== undefined &&
660
+ (pendingEntry.type === "delete" || pendingEntry.type === "lifetime"),
661
+ 0xbf3 /* Unexpected pending data for set/delete op */,
662
+ );
663
+ if (pendingEntry.type === "delete") {
664
+ assert(pendingEntry === typedLocalOpMetadata, 0xbf4 /* Unexpected delete rollback */);
665
+ this.pendingData.splice(pendingEntryIndex, 1);
666
+ // Only emit if rolling back the delete actually results in a value becoming visible.
667
+ if (this.getOptimisticLocalValue(mapOp.key) !== undefined) {
668
+ this.eventEmitter.emit(
669
+ "valueChanged",
670
+ { key: mapOp.key, previousValue: undefined },
671
+ true,
672
+ this.eventEmitter,
673
+ );
674
+ }
675
+ } else if (pendingEntry.type === "lifetime") {
676
+ const pendingKeySet = pendingEntry.keySets.pop();
599
677
  assert(
600
- pendingKeyMessageIds[0] === localOpMetadata.pendingMessageId,
601
- 0x2fa /* Unexpected pending message received */,
678
+ pendingKeySet !== undefined && pendingKeySet === typedLocalOpMetadata,
679
+ 0xbf5 /* Unexpected set rollback */,
602
680
  );
603
- pendingKeyMessageIds.shift();
604
- if (pendingKeyMessageIds.length === 0) {
605
- this.pendingKeys.delete(op.key);
681
+ if (pendingEntry.keySets.length === 0) {
682
+ this.pendingData.splice(pendingEntryIndex, 1);
606
683
  }
684
+ this.eventEmitter.emit(
685
+ "valueChanged",
686
+ { key: mapOp.key, previousValue: pendingKeySet.value.value },
687
+ true,
688
+ this.eventEmitter,
689
+ );
607
690
  }
608
- return false;
609
691
  }
610
-
611
- // If we don't have a NACK op on the key, we need to process the remote ops.
612
- return !local;
613
692
  }
614
693
 
615
694
  /**
@@ -622,191 +701,118 @@ export class MapKernel {
622
701
  process: (
623
702
  op: IMapClearOperation,
624
703
  local: boolean,
625
- localOpMetadata: ListNode<MapLocalOpMetadata> | undefined,
704
+ localOpMetadata: PendingLocalOpMetadata | undefined,
626
705
  ) => {
706
+ this.sequencedData.clear();
627
707
  if (local) {
628
- const removedLocalOpMetadata = this.pendingMapLocalOpMetadata.shift();
708
+ const pendingClear = this.pendingData.shift();
629
709
  assert(
630
- removedLocalOpMetadata !== undefined && removedLocalOpMetadata === localOpMetadata,
631
- 0xbcc /* Processing unexpected local clear op */,
710
+ pendingClear !== undefined &&
711
+ pendingClear.type === "clear" &&
712
+ pendingClear === localOpMetadata,
713
+ 0xbf6 /* Got a local clear message we weren't expecting */,
632
714
  );
633
- assert(
634
- isClearLocalOpMetadata(localOpMetadata.data),
635
- 0x015 /* "pendingMessageId is missing from the local client's clear operation" */,
636
- );
637
- const pendingClearMessageId = this.pendingClearMessageIds.shift();
638
- assert(
639
- pendingClearMessageId === localOpMetadata.data.pendingMessageId,
640
- 0x2fb /* pendingMessageId does not match */,
641
- );
642
- return;
715
+ } else {
716
+ // Only emit for remote ops, we would have already emitted for local ops. Only emit if there
717
+ // is no optimistically-applied local pending clear that would supersede this remote clear.
718
+ if (!this.pendingData.some((entry) => entry.type === "clear")) {
719
+ this.eventEmitter.emit("clear", local, this.eventEmitter);
720
+ }
643
721
  }
644
- if (this.pendingKeys.size > 0) {
645
- this.clearExceptPendingKeys();
646
- return;
647
- }
648
- this.clearCore(local);
649
722
  },
650
- resubmit: (op: IMapClearOperation, localOpMetadata: ListNode<MapLocalOpMetadata>) => {
651
- const removedLocalOpMetadata = localOpMetadata.remove()?.data;
652
- assert(
653
- removedLocalOpMetadata !== undefined,
654
- 0xbcd /* Resubmitting unexpected local clear op */,
655
- );
656
- assert(
657
- isClearLocalOpMetadata(localOpMetadata.data),
658
- 0x2fc /* Invalid localOpMetadata for clear */,
659
- );
660
- // We don't reuse the metadata pendingMessageId but send a new one on each submit.
661
- const pendingClearMessageId = this.pendingClearMessageIds.shift();
662
- assert(
663
- pendingClearMessageId === localOpMetadata.data.pendingMessageId,
664
- 0x2fd /* pendingMessageId does not match */,
665
- );
666
- this.submitMapClearMessage(op, localOpMetadata.data.previousMap);
723
+ resubmit: (op: IMapClearOperation, localOpMetadata: PendingLocalOpMetadata) => {
724
+ this.submitMessage(op, localOpMetadata);
667
725
  },
668
726
  });
669
727
  messageHandlers.set("delete", {
670
728
  process: (
671
729
  op: IMapDeleteOperation,
672
730
  local: boolean,
673
- localOpMetadata: ListNode<MapLocalOpMetadata> | undefined,
731
+ localOpMetadata: PendingLocalOpMetadata | undefined,
674
732
  ) => {
733
+ const { key } = op;
734
+
675
735
  if (local) {
676
- const removedLocalOpMetadata = this.pendingMapLocalOpMetadata.shift();
736
+ const pendingEntryIndex = this.pendingData.findIndex(
737
+ (entry) => entry.type !== "clear" && entry.key === key,
738
+ );
739
+ const pendingEntry = this.pendingData[pendingEntryIndex];
677
740
  assert(
678
- removedLocalOpMetadata !== undefined && removedLocalOpMetadata === localOpMetadata,
679
- 0xbce /* Processing unexpected local delete op */,
741
+ pendingEntry !== undefined &&
742
+ pendingEntry.type === "delete" &&
743
+ pendingEntry === localOpMetadata,
744
+ 0xbf7 /* Got a local delete message we weren't expecting */,
680
745
  );
746
+ this.pendingData.splice(pendingEntryIndex, 1);
747
+
748
+ this.sequencedData.delete(key);
749
+ } else {
750
+ const previousValue: unknown = this.sequencedData.get(key)?.value;
751
+ this.sequencedData.delete(key);
752
+ // Suppress the event if local changes would cause the incoming change to be invisible optimistically.
753
+ if (!this.pendingData.some((entry) => entry.type === "clear" || entry.key === key)) {
754
+ this.eventEmitter.emit(
755
+ "valueChanged",
756
+ { key, previousValue },
757
+ local,
758
+ this.eventEmitter,
759
+ );
760
+ }
681
761
  }
682
- if (!this.needProcessKeyOperation(op, local, localOpMetadata?.data)) {
683
- return;
684
- }
685
- this.deleteCore(op.key, local);
686
762
  },
687
- resubmit: (op: IMapDeleteOperation, localOpMetadata: ListNode<MapLocalOpMetadata>) => {
688
- const removedLocalOpMetadata = localOpMetadata.remove()?.data;
689
- assert(
690
- removedLocalOpMetadata !== undefined,
691
- 0xbcf /* Resubmitting unexpected local delete op */,
692
- );
693
- this.resubmitMapKeyMessage(op, localOpMetadata.data);
763
+ resubmit: (op: IMapDeleteOperation, localOpMetadata: PendingLocalOpMetadata) => {
764
+ this.submitMessage(op, localOpMetadata);
694
765
  },
695
766
  });
696
767
  messageHandlers.set("set", {
697
768
  process: (
698
769
  op: IMapSetOperation,
699
770
  local: boolean,
700
- localOpMetadata: ListNode<MapLocalOpMetadata> | undefined,
771
+ localOpMetadata: PendingLocalOpMetadata | undefined,
701
772
  ) => {
773
+ const { key, value } = op;
774
+
702
775
  if (local) {
703
- const removedLocalOpMetadata = this.pendingMapLocalOpMetadata.shift();
776
+ const pendingEntryIndex = this.pendingData.findIndex(
777
+ (entry) => entry.type !== "clear" && entry.key === key,
778
+ );
779
+ const pendingEntry = this.pendingData[pendingEntryIndex];
704
780
  assert(
705
- removedLocalOpMetadata !== undefined && removedLocalOpMetadata === localOpMetadata,
706
- 0xbd0 /* Processing unexpected local set op */,
781
+ pendingEntry !== undefined && pendingEntry.type === "lifetime",
782
+ 0xbf8 /* Couldn't match local set message to pending lifetime */,
707
783
  );
784
+ const pendingKeySet = pendingEntry.keySets.shift();
785
+ assert(
786
+ pendingKeySet !== undefined && pendingKeySet === localOpMetadata,
787
+ 0xbf9 /* Got a local set message we weren't expecting */,
788
+ );
789
+ if (pendingEntry.keySets.length === 0) {
790
+ this.pendingData.splice(pendingEntryIndex, 1);
791
+ }
792
+
793
+ this.sequencedData.set(key, pendingKeySet.value);
794
+ } else {
795
+ migrateIfSharedSerializable(value, this.serializer, this.handle);
796
+ const localValue: ILocalValue = { value: value.value };
797
+ const previousValue: unknown = this.sequencedData.get(key)?.value;
798
+ this.sequencedData.set(key, localValue);
799
+
800
+ // Suppress the event if local changes would cause the incoming change to be invisible optimistically.
801
+ if (!this.pendingData.some((entry) => entry.type === "clear" || entry.key === key)) {
802
+ this.eventEmitter.emit(
803
+ "valueChanged",
804
+ { key, previousValue },
805
+ local,
806
+ this.eventEmitter,
807
+ );
808
+ }
708
809
  }
709
- if (!this.needProcessKeyOperation(op, local, localOpMetadata?.data)) {
710
- return;
711
- }
712
-
713
- // needProcessKeyOperation should have returned false if local is true
714
- migrateIfSharedSerializable(op.value, this.serializer, this.handle);
715
- this.setCore(op.key, { value: op.value.value }, local);
716
810
  },
717
- resubmit: (op: IMapSetOperation, localOpMetadata: ListNode<MapLocalOpMetadata>) => {
718
- const removedLocalOpMetadata = localOpMetadata.remove()?.data;
719
- assert(
720
- removedLocalOpMetadata !== undefined,
721
- 0xbd1 /* Resubmitting unexpected local set op */,
722
- );
723
- this.resubmitMapKeyMessage(op, localOpMetadata.data);
811
+ resubmit: (op: IMapSetOperation, localOpMetadata: PendingLocalOpMetadata) => {
812
+ this.submitMessage(op, localOpMetadata);
724
813
  },
725
814
  });
726
815
 
727
816
  return messageHandlers;
728
817
  }
729
-
730
- private getMapClearMessageId(): number {
731
- const pendingMessageId = this.nextPendingMessageId++;
732
- this.pendingClearMessageIds.push(pendingMessageId);
733
- return pendingMessageId;
734
- }
735
-
736
- /**
737
- * Submit a clear message to remote clients.
738
- * @param op - The clear message
739
- */
740
- private submitMapClearMessage(
741
- op: IMapClearOperation,
742
- previousMap?: Map<string, ILocalValue>,
743
- ): void {
744
- const pendingMessageId = this.getMapClearMessageId();
745
- const localMetadata = createClearLocalOpMetadata(op, pendingMessageId, previousMap);
746
- const listNode = this.pendingMapLocalOpMetadata.push(localMetadata).first;
747
- this.submitMessage(op, listNode);
748
- }
749
-
750
- private getMapKeyMessageId(op: IMapKeyOperation): number {
751
- const pendingMessageId = this.nextPendingMessageId++;
752
- const pendingMessageIds = this.pendingKeys.get(op.key);
753
- if (pendingMessageIds === undefined) {
754
- this.pendingKeys.set(op.key, [pendingMessageId]);
755
- } else {
756
- pendingMessageIds.push(pendingMessageId);
757
- }
758
- return pendingMessageId;
759
- }
760
-
761
- /**
762
- * Submit a map key message to remote clients.
763
- * @param op - The map key message
764
- * @param previousValue - The value of the key before this op
765
- */
766
- private submitMapKeyMessage(op: IMapKeyOperation, previousValue?: ILocalValue): void {
767
- const pendingMessageId = this.getMapKeyMessageId(op);
768
- const localMetadata = createKeyLocalOpMetadata(op, pendingMessageId, previousValue);
769
- const listNode = this.pendingMapLocalOpMetadata.push(localMetadata).first;
770
- this.submitMessage(op, listNode);
771
- }
772
-
773
- /**
774
- * Submit a map key message to remote clients based on a previous submit.
775
- * @param op - The map key message
776
- * @param localOpMetadata - Metadata from the previous submit
777
- */
778
- private resubmitMapKeyMessage(
779
- op: IMapKeyOperation,
780
- localOpMetadata: MapLocalOpMetadata,
781
- ): void {
782
- assert(
783
- isMapKeyLocalOpMetadata(localOpMetadata),
784
- 0x2fe /* Invalid localOpMetadata in submit */,
785
- );
786
-
787
- // no need to submit messages for op's that have been aborted
788
- const pendingMessageIds = this.pendingKeys.get(op.key);
789
- if (pendingMessageIds === undefined) {
790
- return;
791
- }
792
-
793
- const index = pendingMessageIds.indexOf(localOpMetadata.pendingMessageId);
794
- if (index === -1) {
795
- return;
796
- }
797
-
798
- pendingMessageIds.splice(index, 1);
799
- if (pendingMessageIds.length === 0) {
800
- this.pendingKeys.delete(op.key);
801
- }
802
-
803
- // We don't reuse the metadata pendingMessageId but send a new one on each submit.
804
- const pendingMessageId = this.getMapKeyMessageId(op);
805
- const localMetadata: MapKeyLocalOpMetadata =
806
- localOpMetadata.type === "edit"
807
- ? { type: "edit", pendingMessageId, previousValue: localOpMetadata.previousValue }
808
- : { type: "add", pendingMessageId };
809
- const listNode = this.pendingMapLocalOpMetadata.push(localMetadata).first;
810
- this.submitMessage(op, listNode);
811
- }
812
818
  }