@webex/plugin-meetings 3.12.0-next.6 → 3.12.0-next.60

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 (158) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +15 -2
  3. package/dist/aiEnableRequest/index.js.map +1 -1
  4. package/dist/breakouts/breakout.js +8 -3
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +26 -2
  7. package/dist/breakouts/index.js.map +1 -1
  8. package/dist/config.js +2 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +6 -3
  11. package/dist/constants.js.map +1 -1
  12. package/dist/controls-options-manager/constants.js +11 -1
  13. package/dist/controls-options-manager/constants.js.map +1 -1
  14. package/dist/controls-options-manager/index.js +38 -24
  15. package/dist/controls-options-manager/index.js.map +1 -1
  16. package/dist/controls-options-manager/util.js +91 -0
  17. package/dist/controls-options-manager/util.js.map +1 -1
  18. package/dist/hashTree/constants.js +10 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +716 -370
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +22 -0
  23. package/dist/hashTree/utils.js.map +1 -1
  24. package/dist/index.js +7 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/interceptors/locusRetry.js +23 -8
  27. package/dist/interceptors/locusRetry.js.map +1 -1
  28. package/dist/interpretation/index.js +10 -1
  29. package/dist/interpretation/index.js.map +1 -1
  30. package/dist/interpretation/siLanguage.js +1 -1
  31. package/dist/locus-info/controlsUtils.js +4 -1
  32. package/dist/locus-info/controlsUtils.js.map +1 -1
  33. package/dist/locus-info/index.js +289 -87
  34. package/dist/locus-info/index.js.map +1 -1
  35. package/dist/locus-info/types.js +19 -0
  36. package/dist/locus-info/types.js.map +1 -1
  37. package/dist/media/index.js +3 -1
  38. package/dist/media/index.js.map +1 -1
  39. package/dist/media/properties.js +1 -0
  40. package/dist/media/properties.js.map +1 -1
  41. package/dist/meeting/in-meeting-actions.js +3 -1
  42. package/dist/meeting/in-meeting-actions.js.map +1 -1
  43. package/dist/meeting/index.js +907 -535
  44. package/dist/meeting/index.js.map +1 -1
  45. package/dist/meeting/util.js +19 -2
  46. package/dist/meeting/util.js.map +1 -1
  47. package/dist/meetings/index.js +231 -78
  48. package/dist/meetings/index.js.map +1 -1
  49. package/dist/meetings/meetings.types.js +6 -1
  50. package/dist/meetings/meetings.types.js.map +1 -1
  51. package/dist/meetings/request.js +39 -0
  52. package/dist/meetings/request.js.map +1 -1
  53. package/dist/meetings/util.js +79 -5
  54. package/dist/meetings/util.js.map +1 -1
  55. package/dist/member/index.js +10 -0
  56. package/dist/member/index.js.map +1 -1
  57. package/dist/member/types.js.map +1 -1
  58. package/dist/member/util.js +3 -0
  59. package/dist/member/util.js.map +1 -1
  60. package/dist/metrics/constants.js +4 -1
  61. package/dist/metrics/constants.js.map +1 -1
  62. package/dist/multistream/codec/constants.js +63 -0
  63. package/dist/multistream/codec/constants.js.map +1 -0
  64. package/dist/multistream/mediaRequestManager.js +62 -15
  65. package/dist/multistream/mediaRequestManager.js.map +1 -1
  66. package/dist/multistream/receiveSlot.js +9 -0
  67. package/dist/multistream/receiveSlot.js.map +1 -1
  68. package/dist/reactions/reactions.type.js.map +1 -1
  69. package/dist/recording-controller/index.js +1 -3
  70. package/dist/recording-controller/index.js.map +1 -1
  71. package/dist/types/config.d.ts +2 -0
  72. package/dist/types/constants.d.ts +2 -0
  73. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  74. package/dist/types/controls-options-manager/index.d.ts +10 -0
  75. package/dist/types/hashTree/constants.d.ts +1 -0
  76. package/dist/types/hashTree/hashTreeParser.d.ts +92 -16
  77. package/dist/types/hashTree/utils.d.ts +11 -0
  78. package/dist/types/index.d.ts +2 -0
  79. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  80. package/dist/types/locus-info/index.d.ts +46 -6
  81. package/dist/types/locus-info/types.d.ts +21 -1
  82. package/dist/types/media/properties.d.ts +1 -0
  83. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  84. package/dist/types/meeting/index.d.ts +87 -3
  85. package/dist/types/meeting/util.d.ts +8 -0
  86. package/dist/types/meetings/index.d.ts +30 -2
  87. package/dist/types/meetings/meetings.types.d.ts +15 -0
  88. package/dist/types/meetings/request.d.ts +14 -0
  89. package/dist/types/member/index.d.ts +1 -0
  90. package/dist/types/member/types.d.ts +1 -0
  91. package/dist/types/member/util.d.ts +1 -0
  92. package/dist/types/metrics/constants.d.ts +3 -0
  93. package/dist/types/multistream/codec/constants.d.ts +7 -0
  94. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  95. package/dist/types/reactions/reactions.type.d.ts +3 -0
  96. package/dist/webinar/index.js +361 -235
  97. package/dist/webinar/index.js.map +1 -1
  98. package/package.json +22 -22
  99. package/src/aiEnableRequest/index.ts +16 -0
  100. package/src/breakouts/breakout.ts +3 -1
  101. package/src/breakouts/index.ts +31 -0
  102. package/src/config.ts +2 -0
  103. package/src/constants.ts +5 -1
  104. package/src/controls-options-manager/constants.ts +14 -1
  105. package/src/controls-options-manager/index.ts +47 -24
  106. package/src/controls-options-manager/util.ts +81 -1
  107. package/src/hashTree/constants.ts +9 -0
  108. package/src/hashTree/hashTreeParser.ts +429 -183
  109. package/src/hashTree/utils.ts +17 -0
  110. package/src/index.ts +5 -0
  111. package/src/interceptors/locusRetry.ts +25 -4
  112. package/src/interpretation/index.ts +25 -8
  113. package/src/locus-info/controlsUtils.ts +3 -1
  114. package/src/locus-info/index.ts +291 -97
  115. package/src/locus-info/types.ts +25 -1
  116. package/src/media/index.ts +3 -0
  117. package/src/media/properties.ts +1 -0
  118. package/src/meeting/in-meeting-actions.ts +4 -0
  119. package/src/meeting/index.ts +388 -33
  120. package/src/meeting/util.ts +20 -2
  121. package/src/meetings/index.ts +134 -44
  122. package/src/meetings/meetings.types.ts +19 -0
  123. package/src/meetings/request.ts +43 -0
  124. package/src/meetings/util.ts +97 -1
  125. package/src/member/index.ts +10 -0
  126. package/src/member/types.ts +1 -0
  127. package/src/member/util.ts +3 -0
  128. package/src/metrics/constants.ts +3 -0
  129. package/src/multistream/codec/constants.ts +58 -0
  130. package/src/multistream/mediaRequestManager.ts +119 -28
  131. package/src/multistream/receiveSlot.ts +18 -0
  132. package/src/reactions/reactions.type.ts +3 -0
  133. package/src/recording-controller/index.ts +1 -2
  134. package/src/webinar/index.ts +162 -21
  135. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  136. package/test/unit/spec/breakouts/breakout.ts +9 -3
  137. package/test/unit/spec/breakouts/index.ts +49 -0
  138. package/test/unit/spec/controls-options-manager/index.js +140 -29
  139. package/test/unit/spec/controls-options-manager/util.js +165 -0
  140. package/test/unit/spec/hashTree/hashTreeParser.ts +1508 -149
  141. package/test/unit/spec/hashTree/utils.ts +88 -1
  142. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  143. package/test/unit/spec/interpretation/index.ts +26 -4
  144. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  145. package/test/unit/spec/locus-info/index.js +475 -81
  146. package/test/unit/spec/media/index.ts +31 -0
  147. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  148. package/test/unit/spec/meeting/index.js +1131 -49
  149. package/test/unit/spec/meeting/muteState.js +3 -0
  150. package/test/unit/spec/meeting/utils.js +33 -0
  151. package/test/unit/spec/meetings/index.js +360 -10
  152. package/test/unit/spec/meetings/request.js +141 -0
  153. package/test/unit/spec/meetings/utils.js +189 -0
  154. package/test/unit/spec/member/index.js +7 -0
  155. package/test/unit/spec/member/util.js +24 -0
  156. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  157. package/test/unit/spec/recording-controller/index.js +9 -8
  158. package/test/unit/spec/webinar/index.ts +141 -16
@@ -1,11 +1,13 @@
1
1
  import {cloneDeep, isEmpty, zip} from 'lodash';
2
2
  import HashTree, {LeafDataItem} from './hashTree';
3
3
  import LoggerProxy from '../common/logs/logger-proxy';
4
+ import Metrics from '../metrics';
5
+ import BEHAVIORAL_METRICS from '../metrics/constants';
4
6
  import {Enum, HTTP_VERBS} from '../constants';
5
- import {DataSetNames, EMPTY_HASH} from './constants';
7
+ import {DataSetNames, DATA_SET_INIT_PRIORITY, EMPTY_HASH} from './constants';
6
8
  import {ObjectType, HtMeta, HashTreeObject} from './types';
7
- import {LocusDTO} from '../locus-info/types';
8
- import {deleteNestedObjectsWithHtMeta, isMetadata} from './utils';
9
+ import {LocusDTO, LocusErrorCodes} from '../locus-info/types';
10
+ import {deleteNestedObjectsWithHtMeta, isMetadata, sortByInitPriority} from './utils';
9
11
 
10
12
  export interface DataSet {
11
13
  url: string;
@@ -47,6 +49,7 @@ interface InternalDataSet extends DataSet {
47
49
  hashTree?: HashTree; // set only for visible data sets
48
50
  timer?: ReturnType<typeof setTimeout>;
49
51
  heartbeatWatchdogTimer?: ReturnType<typeof setTimeout>;
52
+ syncAbortController?: AbortController;
50
53
  }
51
54
 
52
55
  type WebexRequestMethod = (options: Record<string, any>) => Promise<any>;
@@ -54,13 +57,24 @@ type WebexRequestMethod = (options: Record<string, any>) => Promise<any>;
54
57
  export const LocusInfoUpdateType = {
55
58
  OBJECTS_UPDATED: 'OBJECTS_UPDATED',
56
59
  MEETING_ENDED: 'MEETING_ENDED',
60
+ LOCUS_NOT_FOUND: 'LOCUS_NOT_FOUND',
57
61
  } as const;
58
62
 
59
63
  export type LocusInfoUpdateType = Enum<typeof LocusInfoUpdateType>;
60
- export type LocusInfoUpdateCallback = (
61
- updateType: LocusInfoUpdateType,
62
- data?: {updatedObjects: HashTreeObject[]}
63
- ) => void;
64
+
65
+ interface LocusUpdatePayloads {
66
+ [LocusInfoUpdateType.OBJECTS_UPDATED]: {updatedObjects: HashTreeObject[]};
67
+ [LocusInfoUpdateType.MEETING_ENDED]: unknown; // No extra data
68
+ [LocusInfoUpdateType.LOCUS_NOT_FOUND]: unknown; // No extra data
69
+ }
70
+
71
+ export type LocusInfoUpdate = {
72
+ [K in keyof LocusUpdatePayloads]: {
73
+ updateType: K;
74
+ } & LocusUpdatePayloads[K];
75
+ }[keyof LocusUpdatePayloads];
76
+
77
+ export type LocusInfoUpdateCallback = (update: LocusInfoUpdate) => void;
64
78
 
65
79
  interface LeafInfo {
66
80
  type: ObjectType;
@@ -75,6 +89,13 @@ interface LeafInfo {
75
89
  */
76
90
  export class MeetingEndedError extends Error {}
77
91
 
92
+ /**
93
+ * This error is thrown when a 404 is received from Locus hash tree endpoints, indicating that the locus URL
94
+ * is no longer valid (e.g. participant moved to a breakout room, or meeting ended).
95
+ * It's handled internally by HashTreeParser and results in LOCUS_NOT_FOUND being sent up.
96
+ */
97
+ export class LocusNotFoundError extends Error {}
98
+
78
99
  /* Currently Locus always sends Metadata objects only in the "self" dataset.
79
100
  * If this ever changes, update all the code that relies on this constant.
80
101
  */
@@ -99,6 +120,10 @@ class HashTreeParser {
99
120
  heartbeatIntervalMs?: number;
100
121
  private excludedDataSets: string[];
101
122
  state: 'active' | 'stopped';
123
+ private syncQueue: Array<{dataSetName: string; reason: string; isInitialization?: boolean}> = [];
124
+ private isSyncInProgress = false;
125
+ private isSyncAllInProgress = false;
126
+ private syncQueueProcessingPromise: Promise<void> = Promise.resolve();
102
127
 
103
128
  /**
104
129
  * Constructor for HashTreeParser
@@ -224,16 +249,16 @@ class HashTreeParser {
224
249
  * @param {DataSet} dataSetInfo The new data set to be added
225
250
  * @returns {Promise}
226
251
  */
227
- private initializeNewVisibleDataSet(
252
+ private async initializeNewVisibleDataSet(
228
253
  visibleDataSetInfo: VisibleDataSetInfo,
229
254
  dataSetInfo: DataSet
230
- ): Promise<{updateType: LocusInfoUpdateType; updatedObjects?: HashTreeObject[]}> {
255
+ ): Promise<void> {
231
256
  if (this.isVisibleDataSet(dataSetInfo.name)) {
232
257
  LoggerProxy.logger.info(
233
258
  `HashTreeParser#initializeNewVisibleDataSet --> ${this.debugId} Data set "${dataSetInfo.name}" already exists, skipping init`
234
259
  );
235
260
 
236
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
261
+ return;
237
262
  }
238
263
 
239
264
  LoggerProxy.logger.info(
@@ -241,7 +266,7 @@ class HashTreeParser {
241
266
  );
242
267
 
243
268
  if (!this.addToVisibleDataSetsList(visibleDataSetInfo)) {
244
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
269
+ return;
245
270
  }
246
271
 
247
272
  const hashTree = new HashTree([], dataSetInfo.leafCount);
@@ -251,51 +276,8 @@ class HashTreeParser {
251
276
  hashTree,
252
277
  };
253
278
 
254
- return this.sendInitializationSyncRequestToLocus(dataSetInfo.name, 'new visible data set');
255
- }
256
-
257
- /**
258
- * Sends a special sync request to Locus with all leaves empty - this is a way to get all the data for a given dataset.
259
- *
260
- * @param {string} datasetName - name of the dataset for which to send the request
261
- * @param {string} debugText - text to include in logs
262
- * @returns {Promise}
263
- */
264
- private sendInitializationSyncRequestToLocus(
265
- datasetName: string,
266
- debugText: string
267
- ): Promise<{updateType: LocusInfoUpdateType; updatedObjects?: HashTreeObject[]}> {
268
- const dataset = this.dataSets[datasetName];
269
-
270
- if (!dataset) {
271
- LoggerProxy.logger.warn(
272
- `HashTreeParser#sendInitializationSyncRequestToLocus --> ${this.debugId} No data set found for ${datasetName}, cannot send the request for leaf data`
273
- );
274
-
275
- return Promise.resolve(null);
276
- }
277
-
278
- const emptyLeavesData = new Array(dataset.leafCount).fill([]);
279
-
280
- LoggerProxy.logger.info(
281
- `HashTreeParser#sendInitializationSyncRequestToLocus --> ${this.debugId} Sending initial sync request to Locus for data set "${datasetName}" with empty leaf data`
282
- );
283
-
284
- return this.sendSyncRequestToLocus(this.dataSets[datasetName], emptyLeavesData).then(
285
- (syncResponse) => {
286
- if (syncResponse) {
287
- return {
288
- updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
289
- updatedObjects: this.parseMessage(
290
- syncResponse,
291
- `via empty leaves /sync API call for ${debugText}`
292
- ),
293
- };
294
- }
295
-
296
- return {updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []};
297
- }
298
- );
279
+ this.enqueueSyncForDataset(dataSetInfo.name, 'new visible data set initialization', true);
280
+ await this.syncQueueProcessingPromise;
299
281
  }
300
282
 
301
283
  /**
@@ -382,9 +364,8 @@ class HashTreeParser {
382
364
  if (this.state === 'stopped') {
383
365
  return;
384
366
  }
385
- const updatedObjects: HashTreeObject[] = [];
386
367
 
387
- for (const dataSet of visibleDataSets) {
368
+ for (const dataSet of sortByInitPriority(visibleDataSets, DATA_SET_INIT_PRIORITY)) {
388
369
  const {name, leafCount, url} = dataSet;
389
370
 
390
371
  if (!this.dataSets[name]) {
@@ -420,19 +401,12 @@ class HashTreeParser {
420
401
  );
421
402
  this.dataSets[name].hashTree = new HashTree([], leafCount);
422
403
 
423
- // eslint-disable-next-line no-await-in-loop
424
- const data = await this.sendInitializationSyncRequestToLocus(name, debugText);
425
-
426
- if (data.updateType === LocusInfoUpdateType.OBJECTS_UPDATED) {
427
- updatedObjects.push(...(data.updatedObjects || []));
428
- }
404
+ this.enqueueSyncForDataset(name, `initialization sync for ${debugText}`, true);
429
405
  }
430
406
  }
431
407
 
432
- this.callLocusInfoUpdateCallback({
433
- updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
434
- updatedObjects,
435
- });
408
+ // wait for all enqueued initialization syncs to complete
409
+ await this.syncQueueProcessingPromise;
436
410
  }
437
411
 
438
412
  /**
@@ -586,12 +560,40 @@ class HashTreeParser {
586
560
  )}`
587
561
  );
588
562
 
563
+ this.cancelPendingSyncsForDataSets(dataSets.map((ds) => ds.name));
564
+
589
565
  dataSets.forEach((dataSet) => {
590
566
  this.updateDataSetInfo(dataSet);
591
567
  this.runSyncAlgorithm(dataSet);
592
568
  });
593
569
  }
594
570
 
571
+ /**
572
+ * Handles known errors that can happen during syncs
573
+ *
574
+ * @param {any} error - The error to handle
575
+ * @returns {boolean} true if the error was recognized and handled, false otherwise
576
+ */
577
+ private handleSyncErrors(error: any) {
578
+ if (error instanceof MeetingEndedError) {
579
+ this.callLocusInfoUpdateCallback({
580
+ updateType: LocusInfoUpdateType.MEETING_ENDED,
581
+ });
582
+
583
+ return true;
584
+ }
585
+ if (error instanceof LocusNotFoundError) {
586
+ this.callLocusInfoUpdateCallback({
587
+ updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND,
588
+ });
589
+ this.stop();
590
+
591
+ return true;
592
+ }
593
+
594
+ return false;
595
+ }
596
+
595
597
  /**
596
598
  * Asynchronously initializes new visible data sets
597
599
  *
@@ -608,11 +610,7 @@ class HashTreeParser {
608
610
  );
609
611
  queueMicrotask(() => {
610
612
  this.initializeNewVisibleDataSets(dataSetsRequiringInitialization).catch((error) => {
611
- if (error instanceof MeetingEndedError) {
612
- this.callLocusInfoUpdateCallback({
613
- updateType: LocusInfoUpdateType.MEETING_ENDED,
614
- });
615
- } else {
613
+ if (!this.handleSyncErrors(error)) {
616
614
  LoggerProxy.logger.warn(
617
615
  `HashTreeParser#queueInitForNewVisibleDataSets --> ${
618
616
  this.debugId
@@ -690,7 +688,14 @@ class HashTreeParser {
690
688
 
691
689
  const {dataSets, locus, metadata} = update;
692
690
 
691
+ LoggerProxy.logger.info(
692
+ `HashTreeParser#handleLocusUpdate --> ${this.debugId} received update with dataSets=${dataSets
693
+ ?.map((ds) => ds.name)
694
+ .join(',')} metadata=${metadata ? 'yes' : 'no'}`
695
+ );
696
+
693
697
  if (!dataSets) {
698
+ // this happens for example when we handle GET /loci response
694
699
  LoggerProxy.logger.info(
695
700
  `HashTreeParser#handleLocusUpdate --> ${this.debugId} received hash tree update without dataSets`
696
701
  );
@@ -794,6 +799,18 @@ class HashTreeParser {
794
799
  }
795
800
  }
796
801
 
802
+ /**
803
+ * Updates the leaf count for a data set, resizing its hash tree accordingly.
804
+ *
805
+ * @param {InternalDataSet} dataSet - The data set to update
806
+ * @param {number} newLeafCount - The new leaf count
807
+ * @returns {void}
808
+ */
809
+ private updateDataSetLeafCount(dataSet: InternalDataSet, newLeafCount: number): void {
810
+ dataSet.hashTree?.resize(newLeafCount);
811
+ dataSet.leafCount = newLeafCount;
812
+ }
813
+
797
814
  /**
798
815
  * Checks for changes in the visible data sets based on the updated objects.
799
816
  * @param {HashTreeObject[]} updatedObjects - The list of updated hash tree objects.
@@ -844,6 +861,8 @@ class HashTreeParser {
844
861
  */
845
862
  private deleteHashTree(dataSetName: string) {
846
863
  this.dataSets[dataSetName].hashTree = undefined;
864
+ this.dataSets[dataSetName].syncAbortController?.abort();
865
+ this.dataSets[dataSetName].syncAbortController = undefined;
847
866
 
848
867
  // we also need to stop the timers as there is no hash tree anymore to sync
849
868
  if (this.dataSets[dataSetName].timer) {
@@ -960,7 +979,7 @@ class HashTreeParser {
960
979
  }
961
980
  const allDataSets = await this.getAllVisibleDataSetsFromLocus();
962
981
 
963
- for (const ds of addedDataSets) {
982
+ for (const ds of sortByInitPriority(addedDataSets, DATA_SET_INIT_PRIORITY)) {
964
983
  const dataSetInfo = allDataSets.find((d) => d.name === ds.name);
965
984
 
966
985
  LoggerProxy.logger.info(
@@ -972,12 +991,8 @@ class HashTreeParser {
972
991
  `HashTreeParser#initializeNewVisibleDataSets --> ${this.debugId} missing info about data set "${ds.name}" in Locus response from visibleDataSetsUrl`
973
992
  );
974
993
  } else {
975
- // we're awaiting in a loop, because in practice there will be only one new data set at a time,
976
- // so no point in trying to parallelize this
977
994
  // eslint-disable-next-line no-await-in-loop
978
- const updates = await this.initializeNewVisibleDataSet(ds, dataSetInfo);
979
-
980
- this.callLocusInfoUpdateCallback(updates);
995
+ await this.initializeNewVisibleDataSet(ds, dataSetInfo);
981
996
  }
982
997
  }
983
998
  }
@@ -997,25 +1012,39 @@ class HashTreeParser {
997
1012
  const {dataSets, visibleDataSetsUrl} = message;
998
1013
 
999
1014
  LoggerProxy.logger.info(
1000
- `HashTreeParser#parseMessage --> ${this.debugId} received message ${debugText || ''}:`,
1001
- message
1015
+ `HashTreeParser#parseMessage --> ${this.debugId} ${
1016
+ debugText || ''
1017
+ } dataSets: ${message.dataSets
1018
+ ?.map(({name, version}) => `${name}:${version}`)
1019
+ .join(',')}, elements: ${message.locusStateElements
1020
+ ?.map(
1021
+ (el) =>
1022
+ `${el.htMeta.elementId.type}:${el.htMeta.elementId.id}:${el.htMeta.elementId.version}${
1023
+ el.data ? '+' : '-'
1024
+ }`
1025
+ )
1026
+ .join(',')}`
1002
1027
  );
1028
+
1003
1029
  if (message.locusStateElements?.length === 0) {
1004
1030
  LoggerProxy.logger.warn(
1005
1031
  `HashTreeParser#parseMessage --> ${this.debugId} got empty locusStateElements!!!`
1006
1032
  );
1007
- // todo: send a metric
1033
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.HASH_TREE_EMPTY_LOCUS_STATE_ELEMENTS, {
1034
+ debugId: this.debugId,
1035
+ });
1008
1036
  }
1009
1037
 
1010
1038
  // first, update our metadata about the datasets with info from the message
1011
1039
  this.visibleDataSetsUrl = visibleDataSetsUrl;
1012
1040
  dataSets.forEach((dataSet) => this.updateDataSetInfo(dataSet));
1041
+ this.cancelPendingSyncsForDataSets(dataSets.map((ds) => ds.name));
1013
1042
 
1014
1043
  const updatedObjects: HashTreeObject[] = [];
1015
1044
 
1016
1045
  // when we detect new visible datasets, it may be that the metadata about them is not
1017
1046
  // available in the message, they will require separate async initialization
1018
- let dataSetsRequiringInitialization = [];
1047
+ let dataSetsRequiringInitialization: VisibleDataSetInfo[] = [];
1019
1048
 
1020
1049
  // first find out if there are any visible data set changes - they're signalled in Metadata object updates
1021
1050
  const metadataUpdates = (message.locusStateElements || []).filter((object) =>
@@ -1023,7 +1052,7 @@ class HashTreeParser {
1023
1052
  );
1024
1053
 
1025
1054
  if (metadataUpdates.length > 0) {
1026
- const updatedMetadataObjects = [];
1055
+ const updatedMetadataObjects: HashTreeObject[] = [];
1027
1056
 
1028
1057
  metadataUpdates.forEach((object) => {
1029
1058
  // todo: once Locus supports it, we will use the "view" field here instead of dataSetNames
@@ -1052,7 +1081,7 @@ class HashTreeParser {
1052
1081
  }
1053
1082
  }
1054
1083
 
1055
- if (message.locusStateElements?.length > 0) {
1084
+ if (message.locusStateElements && message.locusStateElements.length > 0) {
1056
1085
  // by this point we now have this.dataSets setup for data sets from this message
1057
1086
  // and hash trees created for the new visible data sets,
1058
1087
  // so we can now process all the updates from the message
@@ -1148,20 +1177,17 @@ class HashTreeParser {
1148
1177
  * @param {Object} updates parsed from a Locus message
1149
1178
  * @returns {void}
1150
1179
  */
1151
- private callLocusInfoUpdateCallback(updates: {
1152
- updateType: LocusInfoUpdateType;
1153
- updatedObjects?: HashTreeObject[];
1154
- }) {
1180
+ private callLocusInfoUpdateCallback(updates: LocusInfoUpdate) {
1155
1181
  if (this.state === 'stopped') {
1156
1182
  return;
1157
1183
  }
1158
1184
 
1159
- const {updateType, updatedObjects} = updates;
1185
+ const {updateType} = updates;
1160
1186
 
1161
- if (updateType === LocusInfoUpdateType.OBJECTS_UPDATED && updatedObjects?.length > 0) {
1187
+ if (updateType === LocusInfoUpdateType.OBJECTS_UPDATED && updates.updatedObjects?.length > 0) {
1162
1188
  // Filter out updates for objects that already have a higher version in their datasets,
1163
1189
  // or removals for objects that still exist in any of their datasets
1164
- const filteredUpdates = updatedObjects.filter((object) => {
1190
+ const filteredUpdates = updates.updatedObjects.filter((object) => {
1165
1191
  const {elementId} = object.htMeta;
1166
1192
  const {type, id, version} = elementId;
1167
1193
 
@@ -1198,10 +1224,10 @@ class HashTreeParser {
1198
1224
  });
1199
1225
 
1200
1226
  if (filteredUpdates.length > 0) {
1201
- this.locusInfoUpdateCallback(updateType, {updatedObjects: filteredUpdates});
1227
+ this.locusInfoUpdateCallback({updateType, updatedObjects: filteredUpdates});
1202
1228
  }
1203
1229
  } else if (updateType !== LocusInfoUpdateType.OBJECTS_UPDATED) {
1204
- this.locusInfoUpdateCallback(updateType, {updatedObjects});
1230
+ this.locusInfoUpdateCallback({updateType});
1205
1231
  }
1206
1232
  }
1207
1233
 
@@ -1223,81 +1249,240 @@ class HashTreeParser {
1223
1249
  * Performs a sync for the given data set.
1224
1250
  *
1225
1251
  * @param {InternalDataSet} dataSet - The data set to sync
1226
- * @param {string} rootHash - Our current root hash for this data set
1227
1252
  * @param {string} reason - The reason for the sync (used for logging)
1253
+ * @param {boolean} [isInitialization] - Whether this is an initialization sync (sends empty leaves data instead of comparing hashes)
1228
1254
  * @returns {Promise<void>}
1229
1255
  */
1230
1256
  private async performSync(
1231
1257
  dataSet: InternalDataSet,
1232
- rootHash: string,
1233
- reason: string
1258
+ reason: string,
1259
+ isInitialization?: boolean
1234
1260
  ): Promise<void> {
1235
1261
  if (!dataSet.hashTree) {
1236
1262
  return;
1237
1263
  }
1238
1264
 
1265
+ const abortController = dataSet.syncAbortController ?? new AbortController();
1266
+ dataSet.syncAbortController = abortController;
1267
+
1268
+ const {hashTree} = dataSet;
1269
+ const rootHash = hashTree.getRootHash();
1270
+
1239
1271
  try {
1240
1272
  LoggerProxy.logger.info(
1241
1273
  `HashTreeParser#performSync --> ${this.debugId} ${reason}, syncing data set "${dataSet.name}"`
1242
1274
  );
1243
1275
 
1244
- const mismatchedLeavesData: Record<number, LeafDataItem[]> = {};
1276
+ let leavesData: Record<number, LeafDataItem[]> = {};
1245
1277
 
1246
- if (dataSet.leafCount !== 1) {
1247
- let receivedHashes;
1278
+ if (!isInitialization) {
1279
+ if (dataSet.leafCount !== 1) {
1280
+ let receivedHashes;
1248
1281
 
1249
- try {
1250
- // request hashes from sender
1251
- const {hashes, dataSet: latestDataSetInfo} = await this.getHashesFromLocus(
1252
- dataSet.name,
1253
- rootHash
1254
- );
1282
+ try {
1283
+ // request hashes from sender
1284
+ const hashesResult = await this.getHashesFromLocus(dataSet.name, rootHash);
1255
1285
 
1256
- receivedHashes = hashes;
1286
+ if (!hashesResult) {
1287
+ // hashes match, no sync needed
1288
+ return;
1289
+ }
1257
1290
 
1258
- dataSet.hashTree.resize(latestDataSetInfo.leafCount);
1259
- } catch (error) {
1260
- if (error.statusCode === 409) {
1261
- // this is a leaf count mismatch, we should do nothing, just wait for another heartbeat message from Locus
1262
- LoggerProxy.logger.info(
1263
- `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got 409 when fetching hashes for data set "${dataSet.name}": ${error.message}`
1264
- );
1291
+ receivedHashes = hashesResult.hashes;
1292
+
1293
+ this.updateDataSetLeafCount(dataSet, hashesResult.dataSet.leafCount);
1294
+ } catch (error: any) {
1295
+ if (error?.statusCode === 409) {
1296
+ // this is a leaf count mismatch, we should do nothing, just wait for another heartbeat message from Locus
1297
+ LoggerProxy.logger.info(
1298
+ `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got 409 when fetching hashes for data set "${dataSet.name}": ${error.message}`
1299
+ );
1265
1300
 
1266
- return;
1301
+ return;
1302
+ }
1303
+ throw error;
1267
1304
  }
1268
- throw error;
1305
+
1306
+ // identify mismatched leaves
1307
+ const mismatchedLeaveIndexes = hashTree.diffHashes(receivedHashes);
1308
+
1309
+ mismatchedLeaveIndexes.forEach((index) => {
1310
+ leavesData[index] = hashTree.getLeafData(index);
1311
+ });
1312
+ } else {
1313
+ leavesData = {0: hashTree.getLeafData(0)};
1269
1314
  }
1315
+ }
1270
1316
 
1271
- // identify mismatched leaves
1272
- const mismatchedLeaveIndexes = dataSet.hashTree.diffHashes(receivedHashes);
1317
+ if (abortController.signal.aborted) {
1318
+ LoggerProxy.logger.info(
1319
+ `HashTreeParser#performSync --> ${this.debugId} abandoning sync for "${dataSet.name}" before /sync - message received during sync`
1320
+ );
1273
1321
 
1274
- mismatchedLeaveIndexes.forEach((index) => {
1275
- mismatchedLeavesData[index] = dataSet.hashTree.getLeafData(index);
1276
- });
1277
- } else {
1278
- mismatchedLeavesData[0] = dataSet.hashTree.getLeafData(0);
1322
+ return;
1279
1323
  }
1280
1324
  // request sync for mismatched leaves
1281
- if (Object.keys(mismatchedLeavesData).length > 0) {
1282
- const syncResponse = await this.sendSyncRequestToLocus(dataSet, mismatchedLeavesData);
1325
+ let syncResponse: HashTreeMessage | null = null;
1283
1326
 
1284
- // sync API may return nothing (in that case data will arrive via messages)
1285
- // or it may return a response in the same format as messages
1286
- if (syncResponse) {
1287
- this.handleMessage(syncResponse, 'via sync API');
1288
- }
1327
+ if (isInitialization) {
1328
+ syncResponse = await this.sendSyncRequestToLocus(dataSet, {isInitialization: true});
1329
+ } else if (Object.keys(leavesData).length > 0) {
1330
+ syncResponse = await this.sendSyncRequestToLocus(dataSet, {
1331
+ mismatchedLeavesData: leavesData,
1332
+ });
1333
+ }
1334
+
1335
+ // sync API may return nothing (in that case data will arrive via messages)
1336
+ // or it may return a response in the same format as messages
1337
+ // We still need to restart the sync timer as a safety net in case the messages don't arrive.
1338
+ this.runSyncAlgorithm(dataSet);
1339
+
1340
+ if (syncResponse) {
1341
+ // clear the abort controller before processing the response so that
1342
+ // parseMessage() -> cancelPendingSyncsForDataSets() doesn't log a
1343
+ // misleading "aborting sync" message for this already-completed sync
1344
+ dataSet.syncAbortController = undefined;
1345
+ // the format of sync response is the same as messages, so we can reuse the same handler
1346
+ this.handleMessage(syncResponse, 'via sync API');
1289
1347
  }
1290
1348
  } catch (error) {
1291
- if (error instanceof MeetingEndedError) {
1292
- this.callLocusInfoUpdateCallback({
1293
- updateType: LocusInfoUpdateType.MEETING_ENDED,
1294
- });
1295
- } else {
1349
+ if (!this.handleSyncErrors(error)) {
1296
1350
  LoggerProxy.logger.warn(
1297
1351
  `HashTreeParser#performSync --> ${this.debugId} error during sync for data set "${dataSet.name}":`,
1298
1352
  error
1299
1353
  );
1300
1354
  }
1355
+ } finally {
1356
+ dataSet.syncAbortController = undefined;
1357
+ }
1358
+ }
1359
+
1360
+ /**
1361
+ * Cancels any pending or in-flight syncs for the specified data sets.
1362
+ * This removes matching entries from the sync queue and aborts any in-flight sync HTTP requests.
1363
+ *
1364
+ * @param {string[]} dataSetNames - The names of the data sets to cancel syncs for
1365
+ * @returns {void}
1366
+ */
1367
+ private cancelPendingSyncsForDataSets(dataSetNames: string[]): void {
1368
+ const previousLength = this.syncQueue.length;
1369
+
1370
+ this.syncQueue = this.syncQueue.filter((entry) => !dataSetNames.includes(entry.dataSetName));
1371
+
1372
+ if (previousLength !== this.syncQueue.length) {
1373
+ LoggerProxy.logger.info(
1374
+ `HashTreeParser#cancelPendingSyncsForDataSets --> ${this.debugId} removed ${
1375
+ previousLength - this.syncQueue.length
1376
+ } entries from sync queue for data sets: ${dataSetNames.join(', ')}`
1377
+ );
1378
+ }
1379
+
1380
+ for (const name of dataSetNames) {
1381
+ if (this.dataSets[name]?.syncAbortController) {
1382
+ LoggerProxy.logger.info(
1383
+ `HashTreeParser#cancelPendingSyncsForDataSets --> ${this.debugId} aborting in-flight sync for data set "${name}"`
1384
+ );
1385
+ this.dataSets[name].syncAbortController.abort();
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ /**
1391
+ * Enqueues a sync for the given data set. If the data set is already in the queue, the request is ignored.
1392
+ * This ensures that all syncs are executed sequentially and no more than 1 sync runs at a time.
1393
+ *
1394
+ * @param {string} dataSetName - The name of the data set to sync
1395
+ * @param {string} reason - The reason for the sync (used for logging)
1396
+ * @param {boolean} [isInitialization=false] - Whether this is an initialization sync (uses empty leaves data instead of hash comparison)
1397
+ * @returns {void}
1398
+ */
1399
+ private enqueueSyncForDataset(
1400
+ dataSetName: string,
1401
+ reason: string,
1402
+ isInitialization = false
1403
+ ): void {
1404
+ if (this.state === 'stopped') return;
1405
+
1406
+ const existingEntry = this.syncQueue.find((entry) => entry.dataSetName === dataSetName);
1407
+
1408
+ if (existingEntry) {
1409
+ if (isInitialization) {
1410
+ existingEntry.isInitialization = true;
1411
+ }
1412
+ LoggerProxy.logger.info(
1413
+ `HashTreeParser#enqueueSyncForDataset --> ${this.debugId} data set "${dataSetName}" already in sync queue, skipping`
1414
+ );
1415
+
1416
+ return;
1417
+ }
1418
+
1419
+ this.syncQueue.push({dataSetName, reason, isInitialization});
1420
+
1421
+ if (!this.isSyncInProgress) {
1422
+ this.syncQueueProcessingPromise = this.processSyncQueue();
1423
+ }
1424
+ }
1425
+
1426
+ /**
1427
+ * Processes the sync queue sequentially. Only one instance of this method runs at a time.
1428
+ *
1429
+ * @returns {Promise<void>}
1430
+ */
1431
+ private async processSyncQueue(): Promise<void> {
1432
+ if (this.isSyncInProgress) return;
1433
+
1434
+ this.isSyncInProgress = true;
1435
+ try {
1436
+ while (this.syncQueue.length > 0 && this.state !== 'stopped') {
1437
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1438
+ const {dataSetName, reason, isInitialization} = this.syncQueue.shift()!;
1439
+ const dataSet = this.dataSets[dataSetName];
1440
+
1441
+ if (!dataSet?.hashTree) {
1442
+ // eslint-disable-next-line no-continue
1443
+ continue;
1444
+ }
1445
+
1446
+ // eslint-disable-next-line no-await-in-loop
1447
+ await this.performSync(dataSet, reason, isInitialization);
1448
+ }
1449
+ } finally {
1450
+ this.isSyncInProgress = false;
1451
+ }
1452
+ }
1453
+
1454
+ /**
1455
+ * Syncs all data sets that have hash trees, one by one in sequence, using the priority order
1456
+ * provided by sortByInitPriority(). Does nothing if the parser is stopped or if a syncAllDatasets
1457
+ * call is already in progress.
1458
+ *
1459
+ * @returns {Promise<void>}
1460
+ */
1461
+ public async syncAllDatasets(): Promise<void> {
1462
+ if (this.state === 'stopped') return;
1463
+ if (this.isSyncAllInProgress) return;
1464
+
1465
+ this.isSyncAllInProgress = true;
1466
+ try {
1467
+ const dataSetsWithHashTrees = Object.values(this.dataSets)
1468
+ .filter((dataSet) => dataSet?.hashTree)
1469
+ .map((dataSet) => ({name: dataSet.name}));
1470
+
1471
+ const sorted = sortByInitPriority(dataSetsWithHashTrees, DATA_SET_INIT_PRIORITY);
1472
+
1473
+ LoggerProxy.logger.info(
1474
+ `HashTreeParser#syncAllDatasets --> ${this.debugId} syncing datasets: ${sorted
1475
+ .map((ds) => ds.name)
1476
+ .join(', ')}`
1477
+ );
1478
+
1479
+ for (const ds of sorted) {
1480
+ this.enqueueSyncForDataset(ds.name, 'syncAllDatasets');
1481
+ }
1482
+
1483
+ await this.syncQueueProcessingPromise;
1484
+ } finally {
1485
+ this.isSyncAllInProgress = false;
1301
1486
  }
1302
1487
  }
1303
1488
 
@@ -1319,21 +1504,14 @@ class HashTreeParser {
1319
1504
  }
1320
1505
 
1321
1506
  if (!dataSet.hashTree) {
1322
- LoggerProxy.logger.info(
1323
- `HashTreeParser#runSyncAlgorithm --> ${this.debugId} Data set "${dataSet.name}" has no hash tree, skipping sync algorithm`
1324
- );
1507
+ // no hash tree, so no need to do any syncing
1508
+ // we fall into this branch often, because Locus sends dataSets in messages that are not visible to us
1325
1509
 
1326
1510
  return;
1327
1511
  }
1328
1512
 
1329
1513
  dataSet.hashTree.resize(receivedDataSet.leafCount);
1330
1514
 
1331
- // temporary log for the workshop // todo: remove
1332
- const ourCurrentRootHash = dataSet.hashTree.getRootHash();
1333
- LoggerProxy.logger.info(
1334
- `HashTreeParser#runSyncAlgorithm --> ${this.debugId} dataSet="${dataSet.name}" version=${dataSet.version} hashes before starting timer: ours=${ourCurrentRootHash} Locus=${dataSet.root}`
1335
- );
1336
-
1337
1515
  const delay = dataSet.idleMs + this.getWeightedBackoffTime(dataSet.backoff);
1338
1516
 
1339
1517
  if (delay > 0) {
@@ -1341,11 +1519,7 @@ class HashTreeParser {
1341
1519
  clearTimeout(dataSet.timer);
1342
1520
  }
1343
1521
 
1344
- LoggerProxy.logger.info(
1345
- `HashTreeParser#runSyncAlgorithm --> ${this.debugId} setting "${dataSet.name}" sync timer for ${delay}`
1346
- );
1347
-
1348
- dataSet.timer = setTimeout(async () => {
1522
+ dataSet.timer = setTimeout(() => {
1349
1523
  dataSet.timer = undefined;
1350
1524
 
1351
1525
  if (!dataSet.hashTree) {
@@ -1359,15 +1533,10 @@ class HashTreeParser {
1359
1533
  const rootHash = dataSet.hashTree.getRootHash();
1360
1534
 
1361
1535
  if (dataSet.root !== rootHash) {
1362
- await this.performSync(
1363
- dataSet,
1364
- rootHash,
1536
+ this.enqueueSyncForDataset(
1537
+ dataSet.name,
1365
1538
  `Root hash mismatch: received=${dataSet.root}, ours=${rootHash}`
1366
1539
  );
1367
- } else {
1368
- LoggerProxy.logger.info(
1369
- `HashTreeParser#runSyncAlgorithm --> ${this.debugId} "${dataSet.name}" root hash matching: ${rootHash}, version=${dataSet.version}`
1370
- );
1371
1540
  }
1372
1541
  }, delay);
1373
1542
  } else {
@@ -1407,18 +1576,20 @@ class HashTreeParser {
1407
1576
  const backoffTime = this.getWeightedBackoffTime(dataSet.backoff);
1408
1577
  const delay = this.heartbeatIntervalMs + backoffTime;
1409
1578
 
1410
- dataSet.heartbeatWatchdogTimer = setTimeout(async () => {
1579
+ dataSet.heartbeatWatchdogTimer = setTimeout(() => {
1411
1580
  dataSet.heartbeatWatchdogTimer = undefined;
1412
1581
 
1413
1582
  LoggerProxy.logger.warn(
1414
1583
  `HashTreeParser#resetHeartbeatWatchdogs --> ${this.debugId} Heartbeat watchdog fired for data set "${dataSet.name}" - no heartbeat received within expected interval, initiating sync`
1415
1584
  );
1416
1585
 
1417
- await this.performSync(
1418
- dataSet,
1419
- dataSet.hashTree.getRootHash(),
1420
- `heartbeat watchdog expired`
1421
- );
1586
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.HASH_TREE_HEARTBEAT_WATCHDOG_EXPIRED, {
1587
+ debugId: this.debugId,
1588
+ dataSetName: dataSet.name,
1589
+ });
1590
+
1591
+ this.enqueueSyncForDataset(dataSet.name, `heartbeat watchdog expired`);
1592
+ this.resetHeartbeatWatchdogs([dataSet]);
1422
1593
  }, delay);
1423
1594
  }
1424
1595
  }
@@ -1451,7 +1622,10 @@ class HashTreeParser {
1451
1622
  `HashTreeParser#stop --> ${this.debugId} Stopping HashTreeParser, clearing timers and hash trees`
1452
1623
  );
1453
1624
  this.stopAllTimers();
1625
+ this.syncQueue = [];
1454
1626
  Object.values(this.dataSets).forEach((dataSet) => {
1627
+ dataSet.syncAbortController?.abort();
1628
+ dataSet.syncAbortController = undefined;
1455
1629
  dataSet.hashTree = undefined;
1456
1630
  });
1457
1631
  this.visibleDataSets = [];
@@ -1459,17 +1633,27 @@ class HashTreeParser {
1459
1633
  }
1460
1634
 
1461
1635
  /**
1462
- * Resumes the HashTreeParser that was previously stopped.
1636
+ * Cleans up the HashTreeParser, stopping all timers and clearing all internal state.
1637
+ * After calling this, the parser should not be used anymore.
1638
+ * @returns {void}
1639
+ */
1640
+ public cleanUp() {
1641
+ this.stop();
1642
+ this.dataSets = {};
1643
+ }
1644
+
1645
+ /**
1646
+ * Resumes the HashTreeParser that was previously stopped, using a hash tree message.
1463
1647
  * @param {HashTreeMessage} message - The message to resume with, it must contain metadata with visible data sets info
1464
1648
  * @returns {void}
1465
1649
  */
1466
- public resume(message: HashTreeMessage) {
1650
+ public resumeFromMessage(message: HashTreeMessage) {
1467
1651
  // check that message contains metadata with visible data sets - this is essential to be able to resume
1468
1652
  const metadataObject = message.locusStateElements?.find((el) => isMetadata(el));
1469
1653
 
1470
1654
  if (!metadataObject?.data?.visibleDataSets) {
1471
1655
  LoggerProxy.logger.warn(
1472
- `HashTreeParser#resume --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1656
+ `HashTreeParser#resumeFromMessage --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1473
1657
  );
1474
1658
 
1475
1659
  return;
@@ -1490,7 +1674,7 @@ class HashTreeParser {
1490
1674
  };
1491
1675
  }
1492
1676
  LoggerProxy.logger.info(
1493
- `HashTreeParser#resume --> ${
1677
+ `HashTreeParser#resumeFromMessage --> ${
1494
1678
  this.debugId
1495
1679
  } Resuming HashTreeParser with data sets: ${Object.keys(this.dataSets).join(
1496
1680
  ', '
@@ -1501,18 +1685,47 @@ class HashTreeParser {
1501
1685
  this.handleMessage(message, 'on resume');
1502
1686
  }
1503
1687
 
1688
+ /**
1689
+ * Resumes the HashTreeParser that was previously stopped, using a Locus API response.
1690
+ * Unlike resumeFromMessage(), this does not require metadata/dataSets in the input,
1691
+ * as it fetches all necessary information from Locus via initializeFromGetLociResponse.
1692
+ * @param {LocusDTO} locus - locus object from an API response
1693
+ * @returns {Promise}
1694
+ */
1695
+ public async resumeFromApiResponse(locus: LocusDTO) {
1696
+ this.state = 'active';
1697
+ this.dataSets = {};
1698
+
1699
+ LoggerProxy.logger.info(
1700
+ `HashTreeParser#resumeFromApiResponse --> ${this.debugId} Resuming HashTreeParser from API response`
1701
+ );
1702
+
1703
+ await this.initializeFromGetLociResponse(locus);
1704
+ }
1705
+
1504
1706
  private checkForSentinelHttpResponse(error: any, dataSetName?: string) {
1707
+ // 404 for any dataset means the locus is no longer available at this URL - could be replaced or ended
1708
+ // if a dataset is just not visible, we would get a 400
1709
+ if (error.statusCode === 404) {
1710
+ LoggerProxy.logger.info(
1711
+ `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received 404 for data set "${dataSetName}", locus not found`
1712
+ );
1713
+ this.stopAllTimers();
1714
+
1715
+ throw new LocusNotFoundError();
1716
+ }
1717
+
1505
1718
  const isValidDataSetForSentinel =
1506
1719
  dataSetName === undefined ||
1507
1720
  PossibleSentinelMessageDataSetNames.includes(dataSetName.toLowerCase());
1508
1721
 
1509
1722
  if (
1510
- ((error.statusCode === 409 && error.body?.errorCode === 2403004) ||
1511
- error.statusCode === 404) &&
1723
+ error.statusCode === 409 &&
1724
+ error.body?.errorCode === LocusErrorCodes.LOCUS_INACTIVE &&
1512
1725
  isValidDataSetForSentinel
1513
1726
  ) {
1514
1727
  LoggerProxy.logger.info(
1515
- `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received ${error.statusCode} for data set "${dataSetName}", indicating that the meeting has ended`
1728
+ `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received ${error.statusCode}/${error.body?.errorCode} for data set "${dataSetName}", indicating that the meeting has ended`
1516
1729
  );
1517
1730
  this.stopAllTimers();
1518
1731
 
@@ -1524,7 +1737,7 @@ class HashTreeParser {
1524
1737
  * Gets the current hashes from the locus for a specific data set.
1525
1738
  * @param {string} dataSetName
1526
1739
  * @param {string} currentRootHash
1527
- * @returns {string[]}
1740
+ * @returns {Object|null} An object containing the hashes and leaf count, or null if the hashes match and no sync is needed
1528
1741
  */
1529
1742
  private getHashesFromLocus(dataSetName: string, currentRootHash: string) {
1530
1743
  LoggerProxy.logger.info(
@@ -1543,6 +1756,15 @@ class HashTreeParser {
1543
1756
  },
1544
1757
  })
1545
1758
  .then((response) => {
1759
+ if (!response.body || isEmpty(response.body)) {
1760
+ // 204 with empty body means our hashes match Locus, no sync needed
1761
+ LoggerProxy.logger.info(
1762
+ `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got ${response.statusCode} with empty body for data set "${dataSetName}", hashes match - no sync needed`
1763
+ );
1764
+
1765
+ return null;
1766
+ }
1767
+
1546
1768
  const hashes = response.body?.hashes as string[] | undefined;
1547
1769
  const dataSetFromResponse = response.body?.dataSet;
1548
1770
 
@@ -1571,6 +1793,13 @@ class HashTreeParser {
1571
1793
  error
1572
1794
  );
1573
1795
  this.checkForSentinelHttpResponse(error, dataSet.name);
1796
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
1797
+ debugId: this.debugId,
1798
+ dataSetName,
1799
+ request: 'GET /hashtree',
1800
+ statusCode: error.statusCode,
1801
+ reason: error.message,
1802
+ });
1574
1803
 
1575
1804
  throw error;
1576
1805
  });
@@ -1580,29 +1809,43 @@ class HashTreeParser {
1580
1809
  * Sends a sync request to Locus for the specified data set.
1581
1810
  *
1582
1811
  * @param {InternalDataSet} dataSet The data set to sync.
1583
- * @param {Record<number, LeafDataItem[]>} mismatchedLeavesData The mismatched leaves data to include in the sync request.
1812
+ * @param {Object} options Either `{ isInitialization: true }` for init syncs (uses leafCount=1 with empty leaf data) or `{ mismatchedLeavesData }` for normal syncs.
1584
1813
  * @returns {Promise<HashTreeMessage|null>}
1585
1814
  */
1586
1815
  private sendSyncRequestToLocus(
1587
1816
  dataSet: InternalDataSet,
1588
- mismatchedLeavesData: Record<number, LeafDataItem[]>
1817
+ options: {isInitialization: true} | {mismatchedLeavesData: Record<number, LeafDataItem[]>}
1589
1818
  ): Promise<HashTreeMessage | null> {
1590
1819
  LoggerProxy.logger.info(
1591
1820
  `HashTreeParser#sendSyncRequestToLocus --> ${this.debugId} Sending sync request for data set "${dataSet.name}"`
1592
1821
  );
1593
1822
 
1823
+ const isInitialization = 'isInitialization' in options;
1824
+
1594
1825
  const url = `${dataSet.url}/sync`;
1595
- const body = {
1596
- leafCount: dataSet.leafCount,
1826
+ const body: {
1827
+ leafCount: number;
1828
+ leafDataEntries: {leafIndex: number; elementIds: LeafDataItem[]}[];
1829
+ } = {
1830
+ leafCount: isInitialization ? 1 : dataSet.leafCount,
1597
1831
  leafDataEntries: [],
1598
1832
  };
1599
1833
 
1600
- Object.keys(mismatchedLeavesData).forEach((index) => {
1601
- body.leafDataEntries.push({
1602
- leafIndex: parseInt(index, 10),
1603
- elementIds: mismatchedLeavesData[index],
1834
+ if (isInitialization) {
1835
+ // initialization sync: Locus requires leafCount=1 with a single empty leaf
1836
+ body.leafDataEntries.push({leafIndex: 0, elementIds: []});
1837
+ } else {
1838
+ const {mismatchedLeavesData} = options;
1839
+
1840
+ Object.keys(mismatchedLeavesData).forEach((index) => {
1841
+ const leafIndex = parseInt(index, 10);
1842
+
1843
+ body.leafDataEntries.push({
1844
+ leafIndex,
1845
+ elementIds: mismatchedLeavesData[leafIndex],
1846
+ });
1604
1847
  });
1605
- });
1848
+ }
1606
1849
 
1607
1850
  const ourCurrentRootHash = dataSet.hashTree ? dataSet.hashTree.getRootHash() : EMPTY_HASH;
1608
1851
 
@@ -1615,10 +1858,6 @@ class HashTreeParser {
1615
1858
  body,
1616
1859
  })
1617
1860
  .then((resp) => {
1618
- LoggerProxy.logger.info(
1619
- `HashTreeParser#sendSyncRequestToLocus --> ${this.debugId} Sync request succeeded for "${dataSet.name}"`
1620
- );
1621
-
1622
1861
  if (!resp.body || isEmpty(resp.body)) {
1623
1862
  LoggerProxy.logger.info(
1624
1863
  `HashTreeParser#sendSyncRequestToLocus --> ${this.debugId} Got ${resp.statusCode} with empty body for sync request for data set "${dataSet.name}", data should arrive via messages`
@@ -1635,6 +1874,13 @@ class HashTreeParser {
1635
1874
  error
1636
1875
  );
1637
1876
  this.checkForSentinelHttpResponse(error, dataSet.name);
1877
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
1878
+ debugId: this.debugId,
1879
+ dataSetName: dataSet.name,
1880
+ request: 'POST /sync',
1881
+ statusCode: error.statusCode,
1882
+ reason: error.message,
1883
+ });
1638
1884
 
1639
1885
  throw error;
1640
1886
  });