@fluidframework/map 2.0.0-dev.7.3.0.212138 → 2.0.0-dev.7.4.0.215366

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/lib/directory.mjs CHANGED
@@ -11,6 +11,7 @@ import { MessageType } from "@fluidframework/protocol-definitions";
11
11
  import { SharedObject, ValueType } from "@fluidframework/shared-object-base";
12
12
  import { SummaryTreeBuilder } from "@fluidframework/runtime-utils";
13
13
  import * as path from "path-browserify";
14
+ import { RedBlackTree } from "@fluidframework/merge-tree";
14
15
  import { LocalValueMaker, makeSerializable } from "./localValues.mjs";
15
16
  import { pkgVersion } from "./packageVersion.mjs";
16
17
  // We use path-browserify since this code can run safely on the server or the browser.
@@ -65,6 +66,100 @@ DirectoryFactory.Attributes = {
65
66
  snapshotFormatVersion: "0.1",
66
67
  packageVersion: pkgVersion,
67
68
  };
69
+ /**
70
+ * The comparator essentially performs the following procedure to determine the order of subdirectory creation:
71
+ * 1. If subdirectory A has a non-negative 'seq' and subdirectory B has a negative 'seq', subdirectory A is always placed first due to
72
+ * the policy that acknowledged subdirectories precede locally created ones that have not been committed yet.
73
+ *
74
+ * 2. When both subdirectories A and B have a non-negative 'seq', they are compared as follows:
75
+ * - If A and B have different 'seq', they are ordered based on 'seq', and the one with the lower 'seq' will be positioned ahead. Notably this rule
76
+ * should not be applied in the directory ordering, since the lowest 'seq' is -1, when the directory is created locally but not acknowledged yet.
77
+ * - In the case where A and B have equal 'seq', the one with the lower 'clientSeq' will be positioned ahead. This scenario occurs when grouped
78
+ * batching is enabled, and a lower 'clientSeq' indicates that it was processed earlier after the batch was ungrouped.
79
+ *
80
+ * 3. When both subdirectories A and B have a negative 'seq', they are compared as follows:
81
+ * - If A and B have different 'seq', the one with lower 'seq' will be positioned ahead, which indicates the corresponding creation message was
82
+ * acknowledged by the server earlier.
83
+ * - If A and B have equal 'seq', the one with lower 'clientSeq' will be placed at the front. This scenario suggests that both subdirectories A
84
+ * and B were created locally and not acknowledged yet, with the one possessing the lower 'clientSeq' being created earlier.
85
+ *
86
+ * 4. A 'seq' value of zero indicates that the subdirectory was created in detached state, and it is considered acknowledged for the
87
+ * purpose of ordering.
88
+ */
89
+ const seqDataComparator = (a, b) => {
90
+ if (isAcknowledgedOrDetached(a)) {
91
+ if (isAcknowledgedOrDetached(b)) {
92
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
93
+ return a.seq !== b.seq ? a.seq - b.seq : a.clientSeq - b.clientSeq;
94
+ }
95
+ else {
96
+ return -1;
97
+ }
98
+ }
99
+ else {
100
+ if (!isAcknowledgedOrDetached(b)) {
101
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
102
+ return a.seq !== b.seq ? a.seq - b.seq : a.clientSeq - b.clientSeq;
103
+ }
104
+ else {
105
+ return 1;
106
+ }
107
+ }
108
+ };
109
+ function isAcknowledgedOrDetached(seqData) {
110
+ return seqData.seq >= 0;
111
+ }
112
+ /**
113
+ * A utility class for tracking associations between keys and their creation indices.
114
+ * This is relevant to support map iteration in insertion order, see
115
+ * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/%40%40iterator
116
+ *
117
+ * TODO: It can be combined with the creation tracker utilized in SharedMap
118
+ */
119
+ class DirectoryCreationTracker {
120
+ constructor() {
121
+ this.indexToKey = new RedBlackTree(seqDataComparator);
122
+ this.keyToIndex = new Map();
123
+ }
124
+ set(key, seqData) {
125
+ this.indexToKey.put(seqData, key);
126
+ this.keyToIndex.set(key, seqData);
127
+ }
128
+ has(keyOrSeqData) {
129
+ return typeof keyOrSeqData === "string"
130
+ ? this.keyToIndex.has(keyOrSeqData)
131
+ : this.indexToKey.get(keyOrSeqData) !== undefined;
132
+ }
133
+ delete(keyOrSeqData) {
134
+ if (this.has(keyOrSeqData)) {
135
+ if (typeof keyOrSeqData === "string") {
136
+ const seqData = this.keyToIndex.get(keyOrSeqData);
137
+ this.keyToIndex.delete(keyOrSeqData);
138
+ this.indexToKey.remove(seqData);
139
+ }
140
+ else {
141
+ const key = this.indexToKey.get(keyOrSeqData)?.data;
142
+ this.indexToKey.remove(keyOrSeqData);
143
+ this.keyToIndex.delete(key);
144
+ }
145
+ }
146
+ }
147
+ /**
148
+ * Retrieves all subdirectories with creation order that satisfy an optional constraint function.
149
+ * @param constraint - An optional constraint function that filters keys.
150
+ * @returns An array of keys that satisfy the constraint (or all keys if no constraint is provided).
151
+ */
152
+ keys(constraint) {
153
+ const keys = [];
154
+ this.indexToKey.mapRange((node) => {
155
+ if (!constraint || constraint(node.data)) {
156
+ keys.push(node.data);
157
+ }
158
+ return true;
159
+ }, keys);
160
+ return keys;
161
+ }
162
+ }
68
163
  /**
69
164
  * {@inheritDoc ISharedDirectory}
70
165
  *
@@ -120,7 +215,7 @@ export class SharedDirectory extends SharedObject {
120
215
  /**
121
216
  * Root of the SharedDirectory, most operations on the SharedDirectory itself act on the root.
122
217
  */
123
- this.root = new SubDirectory(0, new Set(), this, this.runtime, this.serializer, posix.sep);
218
+ this.root = new SubDirectory({ seq: 0, clientSeq: 0 }, new Set(), this, this.runtime, this.serializer, posix.sep);
124
219
  /**
125
220
  * Mapping of op types to message handlers.
126
221
  */
@@ -349,18 +444,42 @@ export class SharedDirectory extends SharedObject {
349
444
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
350
445
  const [currentSubDir, currentSubDirObject] = stack.pop();
351
446
  if (currentSubDirObject.subdirectories) {
447
+ // Utilize a map to store the seq -> clientSeq for the newly created subdirectory
448
+ const tempSeqNums = new Map();
352
449
  for (const [subdirName, subdirObject] of Object.entries(currentSubDirObject.subdirectories)) {
353
450
  let newSubDir = currentSubDir.getSubDirectory(subdirName);
451
+ let seqData;
354
452
  if (!newSubDir) {
355
453
  const createInfo = subdirObject.ci;
356
- newSubDir = new SubDirectory(
357
- // If csn is -1, then initialize it with 0, otherwise we will never process ops for this
358
- // sub directory. This could be done at serialization time too, but we need to maintain
359
- // back compat too and also we will actually know the state when it was serialized.
360
- createInfo !== undefined && createInfo.csn > -1 ? createInfo.csn : 0, createInfo !== undefined
454
+ // We do not store the client sequence number in the storage because the order has already been
455
+ // guaranteed during the serialization process. As a result, it is only essential to utilize the
456
+ // "fake" client sequence number to signify the loading order, and there is no need to retain
457
+ // the actual client sequence number at this point.
458
+ if (createInfo !== undefined && createInfo.csn > -1) {
459
+ // If csn is -1, then initialize it with 0, otherwise we will never process ops for this
460
+ // sub directory. This could be done at serialization time too, but we need to maintain
461
+ // back compat too and also we will actually know the state when it was serialized.
462
+ if (!tempSeqNums.has(createInfo.csn)) {
463
+ tempSeqNums.set(createInfo.csn, 0);
464
+ }
465
+ let fakeClientSeq = tempSeqNums.get(createInfo.csn);
466
+ seqData = { seq: createInfo.csn, clientSeq: fakeClientSeq };
467
+ tempSeqNums.set(createInfo.csn, ++fakeClientSeq);
468
+ }
469
+ else {
470
+ seqData = {
471
+ seq: 0,
472
+ clientSeq: ++currentSubDir.localCreationSeq,
473
+ };
474
+ }
475
+ newSubDir = new SubDirectory(seqData, createInfo !== undefined
361
476
  ? new Set(createInfo.ccIds)
362
477
  : new Set(), this, this.runtime, this.serializer, posix.join(currentSubDir.absolutePath, subdirName));
363
478
  currentSubDir.populateSubDirectory(subdirName, newSubDir);
479
+ // Record the newly inserted subdirectory to the creation tracker
480
+ currentSubDir.ackedCreationSeqTracker.set(subdirName, {
481
+ ...seqData,
482
+ });
364
483
  }
365
484
  stack.push([newSubDir, subdirObject]);
366
485
  }
@@ -673,9 +792,9 @@ class SubDirectory extends TypedEventEmitter {
673
792
  * @param serializer - The serializer to serialize / parse handles
674
793
  * @param absolutePath - The absolute path of this IDirectory
675
794
  */
676
- constructor(sequenceNumber, clientIds, directory, runtime, serializer, absolutePath) {
795
+ constructor(seqData, clientIds, directory, runtime, serializer, absolutePath) {
677
796
  super();
678
- this.sequenceNumber = sequenceNumber;
797
+ this.seqData = seqData;
679
798
  this.clientIds = clientIds;
680
799
  this.directory = directory;
681
800
  this.runtime = runtime;
@@ -724,6 +843,13 @@ class SubDirectory extends TypedEventEmitter {
724
843
  * The pending ids of any clears that have been performed locally but not yet ack'd from the server
725
844
  */
726
845
  this.pendingClearMessageIds = [];
846
+ /**
847
+ * Assigns a unique ID to each subdirectory created locally but pending for acknowledgement, facilitating the tracking
848
+ * of the creation order.
849
+ */
850
+ this.localCreationSeq = 0;
851
+ this.localCreationSeqTracker = new DirectoryCreationTracker();
852
+ this.ackedCreationSeqTracker = new DirectoryCreationTracker();
727
853
  }
728
854
  dispose(error) {
729
855
  this._deleted = true;
@@ -825,14 +951,18 @@ class SubDirectory extends TypedEventEmitter {
825
951
  return subDir;
826
952
  }
827
953
  /**
828
- * @returns A sequenceNumber which should be used for local changes.
954
+ * @returns The Sequence Data which should be used for local changes.
829
955
  * @remarks While detached, 0 is used rather than -1 to represent a change which should be universally known (as opposed to known
830
956
  * only by the local client). This ensures that if the directory is later attached, none of its data needs to be updated (the values
831
957
  * last set while detached will now be known to any new client, until they are changed).
958
+ *
959
+ * The client sequence number is incremented by 1 for maintaining the internal order of locally created subdirectories
832
960
  * TODO: Convert these conventions to named constants. The semantics used here match those for merge-tree.
833
961
  */
834
962
  getLocalSeq() {
835
- return this.directory.isAttached() ? -1 : 0;
963
+ return this.directory.isAttached()
964
+ ? { seq: -1, clientSeq: ++this.localCreationSeq }
965
+ : { seq: 0, clientSeq: ++this.localCreationSeq };
836
966
  }
837
967
  /**
838
968
  * {@inheritDoc IDirectory.getSubDirectory}
@@ -875,7 +1005,26 @@ class SubDirectory extends TypedEventEmitter {
875
1005
  */
876
1006
  subdirectories() {
877
1007
  this.throwIfDisposed();
878
- return this._subdirectories.entries();
1008
+ const ackedSubdirsInOrder = this.ackedCreationSeqTracker.keys();
1009
+ const localSubdirsInOrder = this.localCreationSeqTracker.keys((key) => !this.ackedCreationSeqTracker.has(key));
1010
+ const subdirNames = [...ackedSubdirsInOrder, ...localSubdirsInOrder];
1011
+ assert(subdirNames.length === this._subdirectories.size, "The count of keys for iteration should be consistent with the size of actual data");
1012
+ const entriesIterator = {
1013
+ index: 0,
1014
+ dirs: this._subdirectories,
1015
+ next() {
1016
+ if (this.index < subdirNames.length) {
1017
+ const subdirName = subdirNames[this.index++];
1018
+ const subdir = this.dirs.get(subdirName);
1019
+ return { value: [subdirName, subdir], done: false };
1020
+ }
1021
+ return { value: undefined, done: true };
1022
+ },
1023
+ [Symbol.iterator]() {
1024
+ return this;
1025
+ },
1026
+ };
1027
+ return entriesIterator;
879
1028
  }
880
1029
  /**
881
1030
  * {@inheritDoc IDirectory.getWorkingDirectory}
@@ -1135,7 +1284,7 @@ class SubDirectory extends TypedEventEmitter {
1135
1284
  return;
1136
1285
  }
1137
1286
  assertNonNullClientId(msg.clientId);
1138
- this.createSubDirectoryCore(op.subdirName, local, msg.sequenceNumber, msg.clientId);
1287
+ this.createSubDirectoryCore(op.subdirName, local, { seq: msg.sequenceNumber, clientSeq: msg.clientSequenceNumber }, msg.clientId);
1139
1288
  }
1140
1289
  /**
1141
1290
  * Apply createSubDirectory operation locally and generate metadata
@@ -1357,7 +1506,7 @@ class SubDirectory extends TypedEventEmitter {
1357
1506
  getSerializableCreateInfo() {
1358
1507
  this.throwIfDisposed();
1359
1508
  const createInfo = {
1360
- csn: this.sequenceNumber,
1509
+ csn: this.seqData.seq,
1361
1510
  ccIds: Array.from(this.clientIds),
1362
1511
  };
1363
1512
  return createInfo;
@@ -1447,6 +1596,17 @@ class SubDirectory extends TypedEventEmitter {
1447
1596
  this.undeleteSubDirectoryTree(localOpMetadata.subDirectory);
1448
1597
  // don't need to register events because deleting never unregistered
1449
1598
  this._subdirectories.set(op.subdirName, localOpMetadata.subDirectory);
1599
+ // Restore the record in creation tracker
1600
+ if (isAcknowledgedOrDetached(localOpMetadata.subDirectory.seqData)) {
1601
+ this.ackedCreationSeqTracker.set(op.subdirName, {
1602
+ ...localOpMetadata.subDirectory.seqData,
1603
+ });
1604
+ }
1605
+ else {
1606
+ this.localCreationSeqTracker.set(op.subdirName, {
1607
+ ...localOpMetadata.subDirectory.seqData,
1608
+ });
1609
+ }
1450
1610
  this.emit("subDirectoryCreated", op.subdirName, true, this);
1451
1611
  }
1452
1612
  this.decrementPendingSubDirCount(this.pendingDeleteSubDirectoriesTracker, op.subDirName);
@@ -1527,7 +1687,7 @@ class SubDirectory extends TypedEventEmitter {
1527
1687
  // and the op was created after the directory was created then apply this op.
1528
1688
  return ((msg.clientId !== null && this.clientIds.has(msg.clientId)) ||
1529
1689
  this.clientIds.has("detached") ||
1530
- (this.sequenceNumber !== -1 && this.sequenceNumber <= msg.referenceSequenceNumber));
1690
+ (this.seqData.seq !== -1 && this.seqData.seq <= msg.referenceSequenceNumber));
1531
1691
  }
1532
1692
  /**
1533
1693
  * If our local operations that have not yet been ack'd will eventually overwrite an incoming operation, we should
@@ -1564,10 +1724,11 @@ class SubDirectory extends TypedEventEmitter {
1564
1724
  // If this is delete op and we have keys in this subDirectory, then we need to delete these
1565
1725
  // keys except the pending ones as they will be sequenced after this delete.
1566
1726
  directory.clearExceptPendingKeys(local);
1567
- // In case of delete op, we need to reset the creation seq number and client ids of
1727
+ // In case of delete op, we need to reset the creation seqNum, clientSeqNum and client ids of
1568
1728
  // creators as the previous directory is getting deleted and we will initialize again when
1569
1729
  // we will receive op for the create again.
1570
- directory.sequenceNumber = -1;
1730
+ directory.seqData.seq = -1;
1731
+ directory.seqData.clientSeq = -1;
1571
1732
  directory.clientIds.clear();
1572
1733
  // Do the same thing for the subtree of the directory. If create is not pending for a child, then just
1573
1734
  // delete it.
@@ -1581,21 +1742,35 @@ class SubDirectory extends TypedEventEmitter {
1581
1742
  }
1582
1743
  };
1583
1744
  const subDirectory = this._subdirectories.get(op.subdirName);
1745
+ // Clear the creation tracker record
1746
+ this.ackedCreationSeqTracker.delete(op.subdirName);
1584
1747
  resetSubDirectoryTree(subDirectory);
1585
1748
  }
1586
1749
  if (op.type === "createSubDirectory") {
1587
1750
  const dir = this._subdirectories.get(op.subdirName);
1588
1751
  // Child sub directory create seq number can't be lower than the parent subdirectory.
1589
1752
  // The sequence number for multiple ops can be the same when multiple createSubDirectory occurs with grouped batching enabled, thus <= and not just <.
1590
- if (this.sequenceNumber !== -1 && this.sequenceNumber <= msg.sequenceNumber) {
1591
- if (dir?.sequenceNumber === -1) {
1592
- // Only set the seq on the first message, could be more
1593
- dir.sequenceNumber = msg.sequenceNumber;
1753
+ if (this.seqData.seq !== -1 && this.seqData.seq <= msg.sequenceNumber) {
1754
+ if (dir?.seqData.seq === -1) {
1755
+ // Only set the sequence data based on the first message
1756
+ dir.seqData.seq = msg.sequenceNumber;
1757
+ dir.seqData.clientSeq = msg.clientSequenceNumber;
1758
+ // set the creation seq in tracker
1759
+ if (!this.ackedCreationSeqTracker.has(op.subdirName) &&
1760
+ !this.pendingDeleteSubDirectoriesTracker.has(op.subdirName)) {
1761
+ this.ackedCreationSeqTracker.set(op.subdirName, {
1762
+ seq: msg.sequenceNumber,
1763
+ clientSeq: msg.clientSequenceNumber,
1764
+ });
1765
+ if (local) {
1766
+ this.localCreationSeqTracker.delete(op.subdirName);
1767
+ }
1768
+ }
1594
1769
  }
1595
1770
  // The client created the dir at or after the dirs seq, so list its client id as a creator.
1596
1771
  if (dir !== undefined &&
1597
1772
  !dir.clientIds.has(msg.clientId) &&
1598
- dir.sequenceNumber <= msg.sequenceNumber) {
1773
+ dir.seqData.seq <= msg.sequenceNumber) {
1599
1774
  dir.clientIds.add(msg.clientId);
1600
1775
  }
1601
1776
  }
@@ -1670,15 +1845,25 @@ class SubDirectory extends TypedEventEmitter {
1670
1845
  * Create subdirectory implementation used for both locally sourced creation as well as incoming remote creation.
1671
1846
  * @param subdirName - The name of the subdirectory being created
1672
1847
  * @param local - Whether the message originated from the local client
1673
- * @param seq - Sequence number at which this directory is created
1848
+ * @param seqData - Sequence number and client sequence number at which this directory is created
1674
1849
  * @param clientId - Id of client which created this directory.
1675
1850
  * @returns True if is newly created, false if it already existed.
1676
1851
  */
1677
- createSubDirectoryCore(subdirName, local, seq, clientId) {
1852
+ createSubDirectoryCore(subdirName, local, seqData, clientId) {
1678
1853
  const subdir = this._subdirectories.get(subdirName);
1679
1854
  if (subdir === undefined) {
1680
1855
  const absolutePath = posix.join(this.absolutePath, subdirName);
1681
- const subDir = new SubDirectory(seq, new Set([clientId]), this.directory, this.runtime, this.serializer, absolutePath);
1856
+ const subDir = new SubDirectory({ ...seqData }, new Set([clientId]), this.directory, this.runtime, this.serializer, absolutePath);
1857
+ /**
1858
+ * Store the sequnce numbers of newly created subdirectory to the proper creation tracker, based
1859
+ * on whether the creation behavior has been ack'd or not
1860
+ */
1861
+ if (!isAcknowledgedOrDetached(seqData)) {
1862
+ this.localCreationSeqTracker.set(subdirName, { ...seqData });
1863
+ }
1864
+ else {
1865
+ this.ackedCreationSeqTracker.set(subdirName, { ...seqData });
1866
+ }
1682
1867
  this.registerEventsOnSubDirectory(subDir, subdirName);
1683
1868
  this._subdirectories.set(subdirName, subDir);
1684
1869
  this.emit("subDirectoryCreated", subdirName, local, this);
@@ -1708,6 +1893,16 @@ class SubDirectory extends TypedEventEmitter {
1708
1893
  // Might want to consider cleaning out the structure more exhaustively though? But not when rollback.
1709
1894
  if (previousValue !== undefined) {
1710
1895
  this._subdirectories.delete(subdirName);
1896
+ /**
1897
+ * Remove the corresponding record from the proper creation tracker, based on whether the subdirectory has been
1898
+ * ack'd already or still not committed yet (could be both).
1899
+ */
1900
+ if (this.ackedCreationSeqTracker.has(subdirName)) {
1901
+ this.ackedCreationSeqTracker.delete(subdirName);
1902
+ }
1903
+ if (this.localCreationSeqTracker.has(subdirName)) {
1904
+ this.localCreationSeqTracker.delete(subdirName);
1905
+ }
1711
1906
  this.disposeSubDirectoryTree(previousValue);
1712
1907
  this.emit("subDirectoryDeleted", subdirName, local, this);
1713
1908
  }