@webex/plugin-meetings 3.12.0-next.7 → 3.12.0-next.71

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 (178) 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 +30 -7
  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 +13 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +880 -382
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +42 -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/dataChannelAuthToken.js +75 -15
  27. package/dist/interceptors/dataChannelAuthToken.js.map +1 -1
  28. package/dist/interceptors/locusRetry.js +23 -8
  29. package/dist/interceptors/locusRetry.js.map +1 -1
  30. package/dist/interpretation/index.js +10 -1
  31. package/dist/interpretation/index.js.map +1 -1
  32. package/dist/interpretation/interpretation.types.js +7 -0
  33. package/dist/interpretation/interpretation.types.js.map +1 -0
  34. package/dist/interpretation/siLanguage.js +1 -1
  35. package/dist/locus-info/controlsUtils.js +4 -1
  36. package/dist/locus-info/controlsUtils.js.map +1 -1
  37. package/dist/locus-info/index.js +298 -87
  38. package/dist/locus-info/index.js.map +1 -1
  39. package/dist/locus-info/types.js +19 -0
  40. package/dist/locus-info/types.js.map +1 -1
  41. package/dist/media/index.js +3 -1
  42. package/dist/media/index.js.map +1 -1
  43. package/dist/media/properties.js +1 -0
  44. package/dist/media/properties.js.map +1 -1
  45. package/dist/meeting/in-meeting-actions.js +3 -1
  46. package/dist/meeting/in-meeting-actions.js.map +1 -1
  47. package/dist/meeting/index.js +1046 -689
  48. package/dist/meeting/index.js.map +1 -1
  49. package/dist/meeting/muteState.js +10 -1
  50. package/dist/meeting/muteState.js.map +1 -1
  51. package/dist/meeting/request.js +5 -2
  52. package/dist/meeting/request.js.map +1 -1
  53. package/dist/meeting/util.js +20 -2
  54. package/dist/meeting/util.js.map +1 -1
  55. package/dist/meeting-info/meeting-info-v2.js +2 -2
  56. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  57. package/dist/meetings/index.js +231 -78
  58. package/dist/meetings/index.js.map +1 -1
  59. package/dist/meetings/meetings.types.js +6 -1
  60. package/dist/meetings/meetings.types.js.map +1 -1
  61. package/dist/meetings/request.js +39 -0
  62. package/dist/meetings/request.js.map +1 -1
  63. package/dist/meetings/util.js +79 -5
  64. package/dist/meetings/util.js.map +1 -1
  65. package/dist/member/index.js +10 -0
  66. package/dist/member/index.js.map +1 -1
  67. package/dist/member/types.js.map +1 -1
  68. package/dist/member/util.js +3 -0
  69. package/dist/member/util.js.map +1 -1
  70. package/dist/metrics/constants.js +4 -1
  71. package/dist/metrics/constants.js.map +1 -1
  72. package/dist/multistream/codec/constants.js +63 -0
  73. package/dist/multistream/codec/constants.js.map +1 -0
  74. package/dist/multistream/mediaRequestManager.js +62 -15
  75. package/dist/multistream/mediaRequestManager.js.map +1 -1
  76. package/dist/multistream/receiveSlot.js +9 -0
  77. package/dist/multistream/receiveSlot.js.map +1 -1
  78. package/dist/reactions/reactions.type.js.map +1 -1
  79. package/dist/recording-controller/index.js +1 -3
  80. package/dist/recording-controller/index.js.map +1 -1
  81. package/dist/types/config.d.ts +2 -0
  82. package/dist/types/constants.d.ts +9 -1
  83. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  84. package/dist/types/controls-options-manager/index.d.ts +10 -0
  85. package/dist/types/hashTree/constants.d.ts +2 -0
  86. package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
  87. package/dist/types/hashTree/utils.d.ts +18 -0
  88. package/dist/types/index.d.ts +3 -0
  89. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  90. package/dist/types/interpretation/interpretation.types.d.ts +10 -0
  91. package/dist/types/locus-info/index.d.ts +50 -6
  92. package/dist/types/locus-info/types.d.ts +21 -1
  93. package/dist/types/media/properties.d.ts +1 -0
  94. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  95. package/dist/types/meeting/index.d.ts +78 -5
  96. package/dist/types/meeting/request.d.ts +1 -0
  97. package/dist/types/meeting/util.d.ts +8 -0
  98. package/dist/types/meetings/index.d.ts +30 -2
  99. package/dist/types/meetings/meetings.types.d.ts +15 -0
  100. package/dist/types/meetings/request.d.ts +14 -0
  101. package/dist/types/member/index.d.ts +1 -0
  102. package/dist/types/member/types.d.ts +1 -0
  103. package/dist/types/member/util.d.ts +1 -0
  104. package/dist/types/metrics/constants.d.ts +3 -0
  105. package/dist/types/multistream/codec/constants.d.ts +7 -0
  106. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  107. package/dist/types/reactions/reactions.type.d.ts +3 -0
  108. package/dist/webinar/index.js +305 -159
  109. package/dist/webinar/index.js.map +1 -1
  110. package/package.json +22 -22
  111. package/src/aiEnableRequest/index.ts +16 -0
  112. package/src/breakouts/breakout.ts +3 -1
  113. package/src/breakouts/index.ts +31 -0
  114. package/src/config.ts +2 -0
  115. package/src/constants.ts +13 -2
  116. package/src/controls-options-manager/constants.ts +14 -1
  117. package/src/controls-options-manager/index.ts +47 -24
  118. package/src/controls-options-manager/util.ts +81 -1
  119. package/src/hashTree/constants.ts +16 -0
  120. package/src/hashTree/hashTreeParser.ts +580 -196
  121. package/src/hashTree/utils.ts +36 -0
  122. package/src/index.ts +6 -0
  123. package/src/interceptors/dataChannelAuthToken.ts +88 -12
  124. package/src/interceptors/locusRetry.ts +25 -4
  125. package/src/interpretation/index.ts +27 -9
  126. package/src/interpretation/interpretation.types.ts +11 -0
  127. package/src/locus-info/controlsUtils.ts +3 -1
  128. package/src/locus-info/index.ts +293 -97
  129. package/src/locus-info/types.ts +25 -1
  130. package/src/media/index.ts +3 -0
  131. package/src/media/properties.ts +1 -0
  132. package/src/meeting/in-meeting-actions.ts +4 -0
  133. package/src/meeting/index.ts +386 -48
  134. package/src/meeting/muteState.ts +10 -1
  135. package/src/meeting/request.ts +11 -0
  136. package/src/meeting/util.ts +21 -2
  137. package/src/meeting-info/meeting-info-v2.ts +4 -2
  138. package/src/meetings/index.ts +134 -44
  139. package/src/meetings/meetings.types.ts +19 -0
  140. package/src/meetings/request.ts +43 -0
  141. package/src/meetings/util.ts +97 -1
  142. package/src/member/index.ts +10 -0
  143. package/src/member/types.ts +1 -0
  144. package/src/member/util.ts +3 -0
  145. package/src/metrics/constants.ts +3 -0
  146. package/src/multistream/codec/constants.ts +58 -0
  147. package/src/multistream/mediaRequestManager.ts +119 -28
  148. package/src/multistream/receiveSlot.ts +18 -0
  149. package/src/reactions/reactions.type.ts +3 -0
  150. package/src/recording-controller/index.ts +1 -2
  151. package/src/webinar/index.ts +214 -36
  152. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  153. package/test/unit/spec/breakouts/breakout.ts +9 -3
  154. package/test/unit/spec/breakouts/index.ts +49 -0
  155. package/test/unit/spec/controls-options-manager/index.js +140 -29
  156. package/test/unit/spec/controls-options-manager/util.js +165 -0
  157. package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
  158. package/test/unit/spec/hashTree/utils.ts +125 -1
  159. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
  160. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  161. package/test/unit/spec/interpretation/index.ts +26 -4
  162. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  163. package/test/unit/spec/locus-info/index.js +487 -81
  164. package/test/unit/spec/media/index.ts +31 -0
  165. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  166. package/test/unit/spec/meeting/index.js +1240 -37
  167. package/test/unit/spec/meeting/muteState.js +81 -0
  168. package/test/unit/spec/meeting/request.js +12 -0
  169. package/test/unit/spec/meeting/utils.js +33 -0
  170. package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
  171. package/test/unit/spec/meetings/index.js +360 -10
  172. package/test/unit/spec/meetings/request.js +141 -0
  173. package/test/unit/spec/meetings/utils.js +189 -0
  174. package/test/unit/spec/member/index.js +7 -0
  175. package/test/unit/spec/member/util.js +24 -0
  176. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  177. package/test/unit/spec/recording-controller/index.js +9 -8
  178. package/test/unit/spec/webinar/index.ts +329 -28
@@ -1,11 +1,19 @@
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, LLM_DATASET_NAMES} 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, sleep, sortByInitPriority} from './utils';
11
+
12
+ export enum SyncAllBackoffType {
13
+ NONE = 'none',
14
+ ONLY_LLM = 'onlyLLM',
15
+ ALL = 'all',
16
+ }
9
17
 
10
18
  export interface DataSet {
11
19
  url: string;
@@ -18,10 +26,12 @@ export interface DataSet {
18
26
  maxMs: number;
19
27
  exponent: number;
20
28
  };
29
+ heartbeatIntervalMs?: number;
21
30
  }
22
31
 
23
32
  export interface RootHashMessage {
24
33
  dataSets: Array<DataSet>;
34
+ heartbeatIntervalMs?: number;
25
35
  }
26
36
  export interface HashTreeMessage {
27
37
  dataSets: Array<DataSet>;
@@ -47,6 +57,7 @@ interface InternalDataSet extends DataSet {
47
57
  hashTree?: HashTree; // set only for visible data sets
48
58
  timer?: ReturnType<typeof setTimeout>;
49
59
  heartbeatWatchdogTimer?: ReturnType<typeof setTimeout>;
60
+ syncAbortController?: AbortController;
50
61
  }
51
62
 
52
63
  type WebexRequestMethod = (options: Record<string, any>) => Promise<any>;
@@ -54,13 +65,24 @@ type WebexRequestMethod = (options: Record<string, any>) => Promise<any>;
54
65
  export const LocusInfoUpdateType = {
55
66
  OBJECTS_UPDATED: 'OBJECTS_UPDATED',
56
67
  MEETING_ENDED: 'MEETING_ENDED',
68
+ LOCUS_NOT_FOUND: 'LOCUS_NOT_FOUND',
57
69
  } as const;
58
70
 
59
71
  export type LocusInfoUpdateType = Enum<typeof LocusInfoUpdateType>;
60
- export type LocusInfoUpdateCallback = (
61
- updateType: LocusInfoUpdateType,
62
- data?: {updatedObjects: HashTreeObject[]}
63
- ) => void;
72
+
73
+ interface LocusUpdatePayloads {
74
+ [LocusInfoUpdateType.OBJECTS_UPDATED]: {updatedObjects: HashTreeObject[]};
75
+ [LocusInfoUpdateType.MEETING_ENDED]: unknown; // No extra data
76
+ [LocusInfoUpdateType.LOCUS_NOT_FOUND]: unknown; // No extra data
77
+ }
78
+
79
+ export type LocusInfoUpdate = {
80
+ [K in keyof LocusUpdatePayloads]: {
81
+ updateType: K;
82
+ } & LocusUpdatePayloads[K];
83
+ }[keyof LocusUpdatePayloads];
84
+
85
+ export type LocusInfoUpdateCallback = (update: LocusInfoUpdate) => void;
64
86
 
65
87
  interface LeafInfo {
66
88
  type: ObjectType;
@@ -75,6 +97,13 @@ interface LeafInfo {
75
97
  */
76
98
  export class MeetingEndedError extends Error {}
77
99
 
100
+ /**
101
+ * This error is thrown when a 404 is received from Locus hash tree endpoints, indicating that the locus URL
102
+ * is no longer valid (e.g. participant moved to a breakout room, or meeting ended).
103
+ * It's handled internally by HashTreeParser and results in LOCUS_NOT_FOUND being sent up.
104
+ */
105
+ export class LocusNotFoundError extends Error {}
106
+
78
107
  /* Currently Locus always sends Metadata objects only in the "self" dataset.
79
108
  * If this ever changes, update all the code that relies on this constant.
80
109
  */
@@ -96,9 +125,17 @@ class HashTreeParser {
96
125
  locusInfoUpdateCallback: LocusInfoUpdateCallback;
97
126
  visibleDataSets: VisibleDataSetInfo[];
98
127
  debugId: string;
99
- heartbeatIntervalMs?: number;
100
128
  private excludedDataSets: string[];
101
129
  state: 'active' | 'stopped';
130
+ private syncQueue: Array<{dataSetName: string; reason: string; isInitialization?: boolean}> = [];
131
+ private isSyncInProgress = false;
132
+ // tracks whether syncAllDatasets is currently in its backoff delay phase and with what scope
133
+ private syncAllBackoffType: SyncAllBackoffType = SyncAllBackoffType.NONE;
134
+ // datasets that received messages during the syncAllDatasets backoff sleep and should be skipped
135
+ private dataSetsSyncedDuringBackoff: Set<string> = new Set();
136
+ private syncQueueProcessingPromise: Promise<void> = Promise.resolve();
137
+ // top-level heartbeat interval from the most recent message, used as fallback when dataset-level value is missing
138
+ private topLevelHeartbeatIntervalMs?: number;
102
139
 
103
140
  /**
104
141
  * Constructor for HashTreeParser
@@ -224,16 +261,16 @@ class HashTreeParser {
224
261
  * @param {DataSet} dataSetInfo The new data set to be added
225
262
  * @returns {Promise}
226
263
  */
227
- private initializeNewVisibleDataSet(
264
+ private async initializeNewVisibleDataSet(
228
265
  visibleDataSetInfo: VisibleDataSetInfo,
229
266
  dataSetInfo: DataSet
230
- ): Promise<{updateType: LocusInfoUpdateType; updatedObjects?: HashTreeObject[]}> {
267
+ ): Promise<void> {
231
268
  if (this.isVisibleDataSet(dataSetInfo.name)) {
232
269
  LoggerProxy.logger.info(
233
270
  `HashTreeParser#initializeNewVisibleDataSet --> ${this.debugId} Data set "${dataSetInfo.name}" already exists, skipping init`
234
271
  );
235
272
 
236
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
273
+ return;
237
274
  }
238
275
 
239
276
  LoggerProxy.logger.info(
@@ -241,7 +278,7 @@ class HashTreeParser {
241
278
  );
242
279
 
243
280
  if (!this.addToVisibleDataSetsList(visibleDataSetInfo)) {
244
- return Promise.resolve({updateType: LocusInfoUpdateType.OBJECTS_UPDATED, updatedObjects: []});
281
+ return;
245
282
  }
246
283
 
247
284
  const hashTree = new HashTree([], dataSetInfo.leafCount);
@@ -251,51 +288,8 @@ class HashTreeParser {
251
288
  hashTree,
252
289
  };
253
290
 
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
- );
291
+ this.enqueueSyncForDataset(dataSetInfo.name, 'new visible data set initialization', true);
292
+ await this.syncQueueProcessingPromise;
299
293
  }
300
294
 
301
295
  /**
@@ -382,9 +376,8 @@ class HashTreeParser {
382
376
  if (this.state === 'stopped') {
383
377
  return;
384
378
  }
385
- const updatedObjects: HashTreeObject[] = [];
386
379
 
387
- for (const dataSet of visibleDataSets) {
380
+ for (const dataSet of sortByInitPriority(visibleDataSets, DATA_SET_INIT_PRIORITY)) {
388
381
  const {name, leafCount, url} = dataSet;
389
382
 
390
383
  if (!this.dataSets[name]) {
@@ -420,19 +413,12 @@ class HashTreeParser {
420
413
  );
421
414
  this.dataSets[name].hashTree = new HashTree([], leafCount);
422
415
 
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
- }
416
+ this.enqueueSyncForDataset(name, `initialization sync for ${debugText}`, true);
429
417
  }
430
418
  }
431
419
 
432
- this.callLocusInfoUpdateCallback({
433
- updateType: LocusInfoUpdateType.OBJECTS_UPDATED,
434
- updatedObjects,
435
- });
420
+ // wait for all enqueued initialization syncs to complete
421
+ await this.syncQueueProcessingPromise;
436
422
  }
437
423
 
438
424
  /**
@@ -586,12 +572,40 @@ class HashTreeParser {
586
572
  )}`
587
573
  );
588
574
 
575
+ this.cancelPendingSyncsForDataSets(dataSets.map((ds) => ds.name));
576
+
589
577
  dataSets.forEach((dataSet) => {
590
578
  this.updateDataSetInfo(dataSet);
591
579
  this.runSyncAlgorithm(dataSet);
592
580
  });
593
581
  }
594
582
 
583
+ /**
584
+ * Handles known errors that can happen during syncs
585
+ *
586
+ * @param {any} error - The error to handle
587
+ * @returns {boolean} true if the error was recognized and handled, false otherwise
588
+ */
589
+ private handleSyncErrors(error: any) {
590
+ if (error instanceof MeetingEndedError) {
591
+ this.callLocusInfoUpdateCallback({
592
+ updateType: LocusInfoUpdateType.MEETING_ENDED,
593
+ });
594
+
595
+ return true;
596
+ }
597
+ if (error instanceof LocusNotFoundError) {
598
+ this.callLocusInfoUpdateCallback({
599
+ updateType: LocusInfoUpdateType.LOCUS_NOT_FOUND,
600
+ });
601
+ this.stop();
602
+
603
+ return true;
604
+ }
605
+
606
+ return false;
607
+ }
608
+
595
609
  /**
596
610
  * Asynchronously initializes new visible data sets
597
611
  *
@@ -608,11 +622,7 @@ class HashTreeParser {
608
622
  );
609
623
  queueMicrotask(() => {
610
624
  this.initializeNewVisibleDataSets(dataSetsRequiringInitialization).catch((error) => {
611
- if (error instanceof MeetingEndedError) {
612
- this.callLocusInfoUpdateCallback({
613
- updateType: LocusInfoUpdateType.MEETING_ENDED,
614
- });
615
- } else {
625
+ if (!this.handleSyncErrors(error)) {
616
626
  LoggerProxy.logger.warn(
617
627
  `HashTreeParser#queueInitForNewVisibleDataSets --> ${
618
628
  this.debugId
@@ -690,7 +700,14 @@ class HashTreeParser {
690
700
 
691
701
  const {dataSets, locus, metadata} = update;
692
702
 
703
+ LoggerProxy.logger.info(
704
+ `HashTreeParser#handleLocusUpdate --> ${this.debugId} received update with dataSets=${dataSets
705
+ ?.map((ds) => ds.name)
706
+ .join(',')} metadata=${metadata ? 'yes' : 'no'}`
707
+ );
708
+
693
709
  if (!dataSets) {
710
+ // this happens for example when we handle GET /loci response
694
711
  LoggerProxy.logger.info(
695
712
  `HashTreeParser#handleLocusUpdate --> ${this.debugId} received hash tree update without dataSets`
696
713
  );
@@ -788,12 +805,25 @@ class HashTreeParser {
788
805
  maxMs: receivedDataSet.backoff.maxMs,
789
806
  exponent: receivedDataSet.backoff.exponent,
790
807
  };
808
+ this.dataSets[receivedDataSet.name].heartbeatIntervalMs = receivedDataSet.heartbeatIntervalMs;
791
809
  LoggerProxy.logger.info(
792
810
  `HashTreeParser#updateDataSetInfo --> ${this.debugId} updated "${receivedDataSet.name}" dataset to version=${receivedDataSet.version}, root=${receivedDataSet.root}`
793
811
  );
794
812
  }
795
813
  }
796
814
 
815
+ /**
816
+ * Updates the leaf count for a data set, resizing its hash tree accordingly.
817
+ *
818
+ * @param {InternalDataSet} dataSet - The data set to update
819
+ * @param {number} newLeafCount - The new leaf count
820
+ * @returns {void}
821
+ */
822
+ private updateDataSetLeafCount(dataSet: InternalDataSet, newLeafCount: number): void {
823
+ dataSet.hashTree?.resize(newLeafCount);
824
+ dataSet.leafCount = newLeafCount;
825
+ }
826
+
797
827
  /**
798
828
  * Checks for changes in the visible data sets based on the updated objects.
799
829
  * @param {HashTreeObject[]} updatedObjects - The list of updated hash tree objects.
@@ -844,6 +874,8 @@ class HashTreeParser {
844
874
  */
845
875
  private deleteHashTree(dataSetName: string) {
846
876
  this.dataSets[dataSetName].hashTree = undefined;
877
+ this.dataSets[dataSetName].syncAbortController?.abort();
878
+ this.dataSets[dataSetName].syncAbortController = undefined;
847
879
 
848
880
  // we also need to stop the timers as there is no hash tree anymore to sync
849
881
  if (this.dataSets[dataSetName].timer) {
@@ -960,7 +992,7 @@ class HashTreeParser {
960
992
  }
961
993
  const allDataSets = await this.getAllVisibleDataSetsFromLocus();
962
994
 
963
- for (const ds of addedDataSets) {
995
+ for (const ds of sortByInitPriority(addedDataSets, DATA_SET_INIT_PRIORITY)) {
964
996
  const dataSetInfo = allDataSets.find((d) => d.name === ds.name);
965
997
 
966
998
  LoggerProxy.logger.info(
@@ -972,12 +1004,8 @@ class HashTreeParser {
972
1004
  `HashTreeParser#initializeNewVisibleDataSets --> ${this.debugId} missing info about data set "${ds.name}" in Locus response from visibleDataSetsUrl`
973
1005
  );
974
1006
  } 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
1007
  // eslint-disable-next-line no-await-in-loop
978
- const updates = await this.initializeNewVisibleDataSet(ds, dataSetInfo);
979
-
980
- this.callLocusInfoUpdateCallback(updates);
1008
+ await this.initializeNewVisibleDataSet(ds, dataSetInfo);
981
1009
  }
982
1010
  }
983
1011
  }
@@ -997,25 +1025,39 @@ class HashTreeParser {
997
1025
  const {dataSets, visibleDataSetsUrl} = message;
998
1026
 
999
1027
  LoggerProxy.logger.info(
1000
- `HashTreeParser#parseMessage --> ${this.debugId} received message ${debugText || ''}:`,
1001
- message
1028
+ `HashTreeParser#parseMessage --> ${this.debugId} ${
1029
+ debugText || ''
1030
+ } dataSets: ${message.dataSets
1031
+ ?.map(({name, version}) => `${name}:${version}`)
1032
+ .join(',')}, elements: ${message.locusStateElements
1033
+ ?.map(
1034
+ (el) =>
1035
+ `${el.htMeta.elementId.type}:${el.htMeta.elementId.id}:${el.htMeta.elementId.version}${
1036
+ el.data ? '+' : '-'
1037
+ }`
1038
+ )
1039
+ .join(',')}`
1002
1040
  );
1041
+
1003
1042
  if (message.locusStateElements?.length === 0) {
1004
1043
  LoggerProxy.logger.warn(
1005
1044
  `HashTreeParser#parseMessage --> ${this.debugId} got empty locusStateElements!!!`
1006
1045
  );
1007
- // todo: send a metric
1046
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.HASH_TREE_EMPTY_LOCUS_STATE_ELEMENTS, {
1047
+ debugId: this.debugId,
1048
+ });
1008
1049
  }
1009
1050
 
1010
1051
  // first, update our metadata about the datasets with info from the message
1011
1052
  this.visibleDataSetsUrl = visibleDataSetsUrl;
1012
1053
  dataSets.forEach((dataSet) => this.updateDataSetInfo(dataSet));
1054
+ this.cancelPendingSyncsForDataSets(dataSets.map((ds) => ds.name));
1013
1055
 
1014
1056
  const updatedObjects: HashTreeObject[] = [];
1015
1057
 
1016
1058
  // when we detect new visible datasets, it may be that the metadata about them is not
1017
1059
  // available in the message, they will require separate async initialization
1018
- let dataSetsRequiringInitialization = [];
1060
+ let dataSetsRequiringInitialization: VisibleDataSetInfo[] = [];
1019
1061
 
1020
1062
  // first find out if there are any visible data set changes - they're signalled in Metadata object updates
1021
1063
  const metadataUpdates = (message.locusStateElements || []).filter((object) =>
@@ -1023,7 +1065,7 @@ class HashTreeParser {
1023
1065
  );
1024
1066
 
1025
1067
  if (metadataUpdates.length > 0) {
1026
- const updatedMetadataObjects = [];
1068
+ const updatedMetadataObjects: HashTreeObject[] = [];
1027
1069
 
1028
1070
  metadataUpdates.forEach((object) => {
1029
1071
  // todo: once Locus supports it, we will use the "view" field here instead of dataSetNames
@@ -1052,7 +1094,7 @@ class HashTreeParser {
1052
1094
  }
1053
1095
  }
1054
1096
 
1055
- if (message.locusStateElements?.length > 0) {
1097
+ if (message.locusStateElements && message.locusStateElements.length > 0) {
1056
1098
  // by this point we now have this.dataSets setup for data sets from this message
1057
1099
  // and hash trees created for the new visible data sets,
1058
1100
  // so we can now process all the updates from the message
@@ -1118,9 +1160,10 @@ class HashTreeParser {
1118
1160
  return;
1119
1161
  }
1120
1162
 
1121
- if (message.heartbeatIntervalMs) {
1122
- this.heartbeatIntervalMs = message.heartbeatIntervalMs;
1163
+ if (message.heartbeatIntervalMs !== undefined) {
1164
+ this.topLevelHeartbeatIntervalMs = message.heartbeatIntervalMs;
1123
1165
  }
1166
+
1124
1167
  if (this.isEndMessage(message)) {
1125
1168
  LoggerProxy.logger.info(
1126
1169
  `HashTreeParser#handleMessage --> ${this.debugId} received sentinel END MEETING message`
@@ -1148,20 +1191,17 @@ class HashTreeParser {
1148
1191
  * @param {Object} updates parsed from a Locus message
1149
1192
  * @returns {void}
1150
1193
  */
1151
- private callLocusInfoUpdateCallback(updates: {
1152
- updateType: LocusInfoUpdateType;
1153
- updatedObjects?: HashTreeObject[];
1154
- }) {
1194
+ private callLocusInfoUpdateCallback(updates: LocusInfoUpdate) {
1155
1195
  if (this.state === 'stopped') {
1156
1196
  return;
1157
1197
  }
1158
1198
 
1159
- const {updateType, updatedObjects} = updates;
1199
+ const {updateType} = updates;
1160
1200
 
1161
- if (updateType === LocusInfoUpdateType.OBJECTS_UPDATED && updatedObjects?.length > 0) {
1201
+ if (updateType === LocusInfoUpdateType.OBJECTS_UPDATED && updates.updatedObjects?.length > 0) {
1162
1202
  // Filter out updates for objects that already have a higher version in their datasets,
1163
1203
  // or removals for objects that still exist in any of their datasets
1164
- const filteredUpdates = updatedObjects.filter((object) => {
1204
+ const filteredUpdates = updates.updatedObjects.filter((object) => {
1165
1205
  const {elementId} = object.htMeta;
1166
1206
  const {type, id, version} = elementId;
1167
1207
 
@@ -1198,10 +1238,10 @@ class HashTreeParser {
1198
1238
  });
1199
1239
 
1200
1240
  if (filteredUpdates.length > 0) {
1201
- this.locusInfoUpdateCallback(updateType, {updatedObjects: filteredUpdates});
1241
+ this.locusInfoUpdateCallback({updateType, updatedObjects: filteredUpdates});
1202
1242
  }
1203
1243
  } else if (updateType !== LocusInfoUpdateType.OBJECTS_UPDATED) {
1204
- this.locusInfoUpdateCallback(updateType, {updatedObjects});
1244
+ this.locusInfoUpdateCallback({updateType});
1205
1245
  }
1206
1246
  }
1207
1247
 
@@ -1223,82 +1263,363 @@ class HashTreeParser {
1223
1263
  * Performs a sync for the given data set.
1224
1264
  *
1225
1265
  * @param {InternalDataSet} dataSet - The data set to sync
1226
- * @param {string} rootHash - Our current root hash for this data set
1227
1266
  * @param {string} reason - The reason for the sync (used for logging)
1267
+ * @param {boolean} [isInitialization] - Whether this is an initialization sync (sends empty leaves data instead of comparing hashes)
1228
1268
  * @returns {Promise<void>}
1229
1269
  */
1230
1270
  private async performSync(
1231
1271
  dataSet: InternalDataSet,
1232
- rootHash: string,
1233
- reason: string
1272
+ reason: string,
1273
+ isInitialization?: boolean
1234
1274
  ): Promise<void> {
1235
1275
  if (!dataSet.hashTree) {
1236
1276
  return;
1237
1277
  }
1238
1278
 
1279
+ const abortController = dataSet.syncAbortController ?? new AbortController();
1280
+ dataSet.syncAbortController = abortController;
1281
+
1282
+ const {hashTree} = dataSet;
1283
+ const rootHash = hashTree.getRootHash();
1284
+
1239
1285
  try {
1240
1286
  LoggerProxy.logger.info(
1241
1287
  `HashTreeParser#performSync --> ${this.debugId} ${reason}, syncing data set "${dataSet.name}"`
1242
1288
  );
1243
1289
 
1244
- const mismatchedLeavesData: Record<number, LeafDataItem[]> = {};
1290
+ let leavesData: Record<number, LeafDataItem[]> = {};
1245
1291
 
1246
- if (dataSet.leafCount !== 1) {
1247
- let receivedHashes;
1292
+ if (!isInitialization) {
1293
+ if (dataSet.leafCount !== 1) {
1294
+ let receivedHashes;
1248
1295
 
1249
- try {
1250
- // request hashes from sender
1251
- const {hashes, dataSet: latestDataSetInfo} = await this.getHashesFromLocus(
1252
- dataSet.name,
1253
- rootHash
1254
- );
1296
+ try {
1297
+ // request hashes from sender
1298
+ const hashesResult = await this.getHashesFromLocus(dataSet.name, rootHash);
1255
1299
 
1256
- receivedHashes = hashes;
1300
+ if (!hashesResult) {
1301
+ // hashes match, no sync needed
1302
+ return;
1303
+ }
1257
1304
 
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
- );
1305
+ receivedHashes = hashesResult.hashes;
1306
+
1307
+ this.updateDataSetLeafCount(dataSet, hashesResult.dataSet.leafCount);
1308
+ } catch (error: any) {
1309
+ if (error?.statusCode === 409) {
1310
+ // this is a leaf count mismatch, we should do nothing, just wait for another heartbeat message from Locus
1311
+ LoggerProxy.logger.info(
1312
+ `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got 409 when fetching hashes for data set "${dataSet.name}": ${error.message}`
1313
+ );
1265
1314
 
1266
- return;
1315
+ return;
1316
+ }
1317
+ throw error;
1267
1318
  }
1268
- throw error;
1319
+
1320
+ // identify mismatched leaves
1321
+ const mismatchedLeaveIndexes = hashTree.diffHashes(receivedHashes);
1322
+
1323
+ mismatchedLeaveIndexes.forEach((index) => {
1324
+ leavesData[index] = hashTree.getLeafData(index);
1325
+ });
1326
+ } else {
1327
+ leavesData = {0: hashTree.getLeafData(0)};
1269
1328
  }
1329
+ }
1270
1330
 
1271
- // identify mismatched leaves
1272
- const mismatchedLeaveIndexes = dataSet.hashTree.diffHashes(receivedHashes);
1331
+ if (abortController.signal.aborted) {
1332
+ LoggerProxy.logger.info(
1333
+ `HashTreeParser#performSync --> ${this.debugId} abandoning sync for "${dataSet.name}" before /sync - message received during sync`
1334
+ );
1273
1335
 
1274
- mismatchedLeaveIndexes.forEach((index) => {
1275
- mismatchedLeavesData[index] = dataSet.hashTree.getLeafData(index);
1276
- });
1277
- } else {
1278
- mismatchedLeavesData[0] = dataSet.hashTree.getLeafData(0);
1336
+ return;
1279
1337
  }
1280
1338
  // request sync for mismatched leaves
1281
- if (Object.keys(mismatchedLeavesData).length > 0) {
1282
- const syncResponse = await this.sendSyncRequestToLocus(dataSet, mismatchedLeavesData);
1339
+ let syncResponse: HashTreeMessage | null = null;
1283
1340
 
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
- }
1341
+ if (isInitialization) {
1342
+ syncResponse = await this.sendSyncRequestToLocus(dataSet, {isInitialization: true});
1343
+ } else if (Object.keys(leavesData).length > 0) {
1344
+ syncResponse = await this.sendSyncRequestToLocus(dataSet, {
1345
+ mismatchedLeavesData: leavesData,
1346
+ });
1347
+ }
1348
+
1349
+ // sync API may return nothing (in that case data will arrive via messages)
1350
+ // or it may return a response in the same format as messages
1351
+ // We still need to restart the sync timer as a safety net in case the messages don't arrive.
1352
+ this.runSyncAlgorithm(dataSet);
1353
+
1354
+ if (syncResponse) {
1355
+ // clear the abort controller before processing the response so that
1356
+ // parseMessage() -> cancelPendingSyncsForDataSets() doesn't log a
1357
+ // misleading "aborting sync" message for this already-completed sync
1358
+ dataSet.syncAbortController = undefined;
1359
+
1360
+ // the format of sync response is the same as messages, so we can reuse the same handler
1361
+ this.handleMessage(
1362
+ syncResponse,
1363
+ `via sync API (${
1364
+ isInitialization ? 'init' : `${Object.keys(leavesData).length} mismatched leaves`
1365
+ })`
1366
+ );
1289
1367
  }
1290
1368
  } catch (error) {
1291
- if (error instanceof MeetingEndedError) {
1292
- this.callLocusInfoUpdateCallback({
1293
- updateType: LocusInfoUpdateType.MEETING_ENDED,
1294
- });
1295
- } else {
1369
+ if (!this.handleSyncErrors(error)) {
1296
1370
  LoggerProxy.logger.warn(
1297
1371
  `HashTreeParser#performSync --> ${this.debugId} error during sync for data set "${dataSet.name}":`,
1298
1372
  error
1299
1373
  );
1300
1374
  }
1375
+ } finally {
1376
+ dataSet.syncAbortController = undefined;
1377
+ }
1378
+ }
1379
+
1380
+ /**
1381
+ * Cancels any pending or in-flight syncs for the specified data sets.
1382
+ * This removes matching entries from the sync queue and aborts any in-flight sync HTTP requests.
1383
+ *
1384
+ * @param {string[]} dataSetNames - The names of the data sets to cancel syncs for
1385
+ * @returns {void}
1386
+ */
1387
+ private cancelPendingSyncsForDataSets(dataSetNames: string[]): void {
1388
+ const previousLength = this.syncQueue.length;
1389
+
1390
+ this.syncQueue = this.syncQueue.filter((entry) => !dataSetNames.includes(entry.dataSetName));
1391
+
1392
+ if (previousLength !== this.syncQueue.length) {
1393
+ LoggerProxy.logger.info(
1394
+ `HashTreeParser#cancelPendingSyncsForDataSets --> ${this.debugId} removed ${
1395
+ previousLength - this.syncQueue.length
1396
+ } entries from sync queue for data sets: ${dataSetNames.join(', ')}`
1397
+ );
1398
+ }
1399
+
1400
+ this.markDataSetsForSyncAllBackoffSkip(dataSetNames);
1401
+ this.abortInFlightSyncs(dataSetNames);
1402
+ }
1403
+
1404
+ /**
1405
+ * If a syncAllDatasets backoff sleep is in progress, marks the given data sets to be skipped
1406
+ * after the sleep completes.
1407
+ *
1408
+ * @param {string[]} dataSetNames - The names of the data sets to mark
1409
+ * @returns {void}
1410
+ */
1411
+ private markDataSetsForSyncAllBackoffSkip(dataSetNames: string[]): void {
1412
+ if (this.syncAllBackoffType !== SyncAllBackoffType.NONE) {
1413
+ for (const name of dataSetNames) {
1414
+ this.dataSetsSyncedDuringBackoff.add(name);
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ /**
1420
+ * Aborts any in-flight sync HTTP requests for the specified data sets.
1421
+ *
1422
+ * @param {string[]} dataSetNames - The names of the data sets whose syncs should be aborted
1423
+ * @returns {void}
1424
+ */
1425
+ private abortInFlightSyncs(dataSetNames: string[]): void {
1426
+ for (const name of dataSetNames) {
1427
+ if (this.dataSets[name]?.syncAbortController) {
1428
+ LoggerProxy.logger.info(
1429
+ `HashTreeParser#cancelPendingSyncsForDataSets --> ${this.debugId} aborting in-flight sync for data set "${name}"`
1430
+ );
1431
+ this.dataSets[name].syncAbortController.abort();
1432
+ }
1433
+ }
1434
+ }
1435
+
1436
+ /**
1437
+ * Enqueues a sync for the given data set. If the data set is already in the queue, the request is ignored.
1438
+ * This ensures that all syncs are executed sequentially and no more than 1 sync runs at a time.
1439
+ *
1440
+ * @param {string} dataSetName - The name of the data set to sync
1441
+ * @param {string} reason - The reason for the sync (used for logging)
1442
+ * @param {boolean} [isInitialization=false] - Whether this is an initialization sync (uses empty leaves data instead of hash comparison)
1443
+ * @returns {void}
1444
+ */
1445
+ private enqueueSyncForDataset(
1446
+ dataSetName: string,
1447
+ reason: string,
1448
+ isInitialization = false
1449
+ ): void {
1450
+ if (this.state === 'stopped') return;
1451
+
1452
+ const existingEntry = this.syncQueue.find((entry) => entry.dataSetName === dataSetName);
1453
+
1454
+ if (existingEntry) {
1455
+ if (isInitialization) {
1456
+ existingEntry.isInitialization = true;
1457
+ }
1458
+ LoggerProxy.logger.info(
1459
+ `HashTreeParser#enqueueSyncForDataset --> ${this.debugId} data set "${dataSetName}" already in sync queue, skipping`
1460
+ );
1461
+
1462
+ return;
1463
+ }
1464
+
1465
+ this.syncQueue.push({dataSetName, reason, isInitialization});
1466
+
1467
+ if (!this.isSyncInProgress) {
1468
+ this.syncQueueProcessingPromise = this.processSyncQueue();
1469
+ }
1470
+ }
1471
+
1472
+ /**
1473
+ * Processes the sync queue sequentially. Only one instance of this method runs at a time.
1474
+ *
1475
+ * @returns {Promise<void>}
1476
+ */
1477
+ private async processSyncQueue(): Promise<void> {
1478
+ if (this.isSyncInProgress) return;
1479
+
1480
+ this.isSyncInProgress = true;
1481
+ try {
1482
+ while (this.syncQueue.length > 0 && this.state !== 'stopped') {
1483
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1484
+ const {dataSetName, reason, isInitialization} = this.syncQueue.shift()!;
1485
+ const dataSet = this.dataSets[dataSetName];
1486
+
1487
+ if (!dataSet?.hashTree) {
1488
+ // eslint-disable-next-line no-continue
1489
+ continue;
1490
+ }
1491
+
1492
+ // eslint-disable-next-line no-await-in-loop
1493
+ await this.performSync(dataSet, reason, isInitialization);
1494
+ }
1495
+ } finally {
1496
+ this.isSyncInProgress = false;
1497
+ }
1498
+ }
1499
+
1500
+ /**
1501
+ * sets the backoff type for syncAllDatasets calls, which determines the scope of datasets that will be synced after the backoff delay.
1502
+ *
1503
+ * @param {boolean} onlyLLM - Whether the backoff is for a syncAllDatasets call that is syncing only LLM datasets
1504
+ * @returns {void}
1505
+ */
1506
+ private setSyncAllBackoffType(onlyLLM: boolean): void {
1507
+ this.syncAllBackoffType = onlyLLM ? SyncAllBackoffType.ONLY_LLM : SyncAllBackoffType.ALL;
1508
+ }
1509
+
1510
+ /**
1511
+ * Checks if a syncAll backoff is already in progress. If so, upgrades the scope from
1512
+ * onlyLLM to all datasets when the new call has a broader scope.
1513
+ *
1514
+ * @param {boolean} onlyLLM - Whether the current call is for LLM datasets only
1515
+ * @returns {boolean} true if a backoff is already pending (caller should return early)
1516
+ */
1517
+ private tryUpgradePendingBackoff(onlyLLM: boolean): boolean {
1518
+ if (this.syncAllBackoffType !== SyncAllBackoffType.NONE) {
1519
+ if (!onlyLLM && this.syncAllBackoffType === SyncAllBackoffType.ONLY_LLM) {
1520
+ this.setSyncAllBackoffType(false);
1521
+ LoggerProxy.logger.info(
1522
+ `HashTreeParser#syncAllDatasets --> ${this.debugId} upgraded pending syncAll from onlyLLM to all datasets`
1523
+ );
1524
+ }
1525
+
1526
+ return true;
1527
+ }
1528
+
1529
+ return false;
1530
+ }
1531
+
1532
+ /**
1533
+ * Syncs all data sets that have hash trees, one by one in sequence, using the priority order
1534
+ * provided by sortByInitPriority().
1535
+ *
1536
+ * If a call is already waiting in the backoff delay phase, a new call with a broader scope
1537
+ * (onlyLLM=false) will upgrade the pending scope, and the dataset list will be computed after
1538
+ * the backoff using the upgraded scope. After the backoff, the sync queue handles deduplication
1539
+ * so no guard is needed.
1540
+ *
1541
+ * @param {Object} [options={}] - Options for syncing
1542
+ * @param {boolean} [options.onlyLLM=false] - Whether to sync only LLM based data sets
1543
+ * @returns {Promise<void>}
1544
+ */
1545
+ public async syncAllDatasets(options: {onlyLLM?: boolean} = {}): Promise<void> {
1546
+ const {onlyLLM = false} = options;
1547
+ if (this.state === 'stopped') return;
1548
+
1549
+ // if we're already in the backoff delay phase, try to upgrade the scope instead of starting a new one
1550
+ if (this.tryUpgradePendingBackoff(onlyLLM)) {
1551
+ return;
1552
+ }
1553
+
1554
+ const dataSetsToSync = this.getSortedDataSetsWithHashTrees(onlyLLM);
1555
+
1556
+ if (dataSetsToSync.length === 0) return;
1557
+
1558
+ this.setSyncAllBackoffType(onlyLLM);
1559
+
1560
+ const delay = this.getWeightedBackoffTime(dataSetsToSync[0].backoff);
1561
+
1562
+ LoggerProxy.logger.info(
1563
+ `HashTreeParser#syncAllDatasets --> ${this.debugId} starting backoff delay of ${delay}ms (onlyLLM=${onlyLLM})`
1564
+ );
1565
+
1566
+ // delay the start of the syncs - this is a Locus requirement to avoid thundering herd issues
1567
+ await sleep(delay);
1568
+
1569
+ // read the (possibly upgraded) scope and clear the backoff flag
1570
+ const effectiveBackoffType = this.syncAllBackoffType;
1571
+ const skippedDataSets = this.dataSetsSyncedDuringBackoff;
1572
+
1573
+ this.syncAllBackoffType = SyncAllBackoffType.NONE;
1574
+ this.dataSetsSyncedDuringBackoff = new Set();
1575
+
1576
+ if ((this.state as string) === 'stopped') return;
1577
+
1578
+ // re-evaluate the dataset list after the sleep, since the scope may have been upgraded
1579
+ // and exclude datasets that received messages during the backoff sleep
1580
+ const effectiveDataSetsToSync = this.getSortedDataSetsWithHashTrees(
1581
+ effectiveBackoffType === SyncAllBackoffType.ONLY_LLM
1582
+ ).filter((ds) => !skippedDataSets.has(ds.name));
1583
+
1584
+ if (skippedDataSets.size > 0) {
1585
+ LoggerProxy.logger.info(
1586
+ `HashTreeParser#syncAllDatasets --> ${
1587
+ this.debugId
1588
+ } skipping datasets that received messages during backoff: ${[...skippedDataSets].join(
1589
+ ', '
1590
+ )}`
1591
+ );
1592
+ }
1593
+
1594
+ LoggerProxy.logger.info(
1595
+ `HashTreeParser#syncAllDatasets --> ${this.debugId} syncing ${
1596
+ effectiveBackoffType === SyncAllBackoffType.ONLY_LLM ? 'only LLM' : 'all'
1597
+ } datasets: ${effectiveDataSetsToSync.map((ds) => ds.name).join(', ')}`
1598
+ );
1599
+
1600
+ for (const ds of effectiveDataSetsToSync) {
1601
+ this.enqueueSyncForDataset(ds.name, 'syncAllDatasets');
1602
+ }
1603
+
1604
+ await this.syncQueueProcessingPromise;
1605
+ }
1606
+
1607
+ /**
1608
+ * Returns the list of data sets that have hash trees, sorted by the priority order provided by sortByInitPriority().
1609
+ *
1610
+ * @param {boolean} onlyLLM - Whether to include only LLM based data sets
1611
+ * @returns {Array<{name: string, backoff: {maxMs: number, exponent: number}}>} The sorted list of data sets with their backoff configurations
1612
+ */
1613
+ private getSortedDataSetsWithHashTrees(onlyLLM: boolean) {
1614
+ let dataSets = Object.values(this.dataSets)
1615
+ .filter((dataSet) => dataSet?.hashTree)
1616
+ .map((dataSet) => ({name: dataSet.name, backoff: dataSet.backoff}));
1617
+
1618
+ if (onlyLLM) {
1619
+ dataSets = dataSets.filter((ds) => LLM_DATASET_NAMES.includes(ds.name));
1301
1620
  }
1621
+
1622
+ return sortByInitPriority(dataSets, DATA_SET_INIT_PRIORITY);
1302
1623
  }
1303
1624
 
1304
1625
  /**
@@ -1319,21 +1640,14 @@ class HashTreeParser {
1319
1640
  }
1320
1641
 
1321
1642
  if (!dataSet.hashTree) {
1322
- LoggerProxy.logger.info(
1323
- `HashTreeParser#runSyncAlgorithm --> ${this.debugId} Data set "${dataSet.name}" has no hash tree, skipping sync algorithm`
1324
- );
1643
+ // no hash tree, so no need to do any syncing
1644
+ // we fall into this branch often, because Locus sends dataSets in messages that are not visible to us
1325
1645
 
1326
1646
  return;
1327
1647
  }
1328
1648
 
1329
1649
  dataSet.hashTree.resize(receivedDataSet.leafCount);
1330
1650
 
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
1651
  const delay = dataSet.idleMs + this.getWeightedBackoffTime(dataSet.backoff);
1338
1652
 
1339
1653
  if (delay > 0) {
@@ -1341,11 +1655,7 @@ class HashTreeParser {
1341
1655
  clearTimeout(dataSet.timer);
1342
1656
  }
1343
1657
 
1344
- LoggerProxy.logger.info(
1345
- `HashTreeParser#runSyncAlgorithm --> ${this.debugId} setting "${dataSet.name}" sync timer for ${delay}`
1346
- );
1347
-
1348
- dataSet.timer = setTimeout(async () => {
1658
+ dataSet.timer = setTimeout(() => {
1349
1659
  dataSet.timer = undefined;
1350
1660
 
1351
1661
  if (!dataSet.hashTree) {
@@ -1359,15 +1669,10 @@ class HashTreeParser {
1359
1669
  const rootHash = dataSet.hashTree.getRootHash();
1360
1670
 
1361
1671
  if (dataSet.root !== rootHash) {
1362
- await this.performSync(
1363
- dataSet,
1364
- rootHash,
1672
+ this.enqueueSyncForDataset(
1673
+ dataSet.name,
1365
1674
  `Root hash mismatch: received=${dataSet.root}, ours=${rootHash}`
1366
1675
  );
1367
- } else {
1368
- LoggerProxy.logger.info(
1369
- `HashTreeParser#runSyncAlgorithm --> ${this.debugId} "${dataSet.name}" root hash matching: ${rootHash}, version=${dataSet.version}`
1370
- );
1371
1676
  }
1372
1677
  }, delay);
1373
1678
  } else {
@@ -1387,38 +1692,39 @@ class HashTreeParser {
1387
1692
  * @returns {void}
1388
1693
  */
1389
1694
  private resetHeartbeatWatchdogs(receivedDataSets: Array<DataSet>): void {
1390
- if (!this.heartbeatIntervalMs) {
1391
- return;
1392
- }
1393
-
1394
1695
  for (const receivedDataSet of receivedDataSets) {
1395
1696
  const dataSet = this.dataSets[receivedDataSet.name];
1396
1697
 
1397
- if (!dataSet?.hashTree) {
1398
- // eslint-disable-next-line no-continue
1399
- continue;
1400
- }
1401
-
1402
1698
  if (dataSet.heartbeatWatchdogTimer) {
1403
1699
  clearTimeout(dataSet.heartbeatWatchdogTimer);
1404
1700
  dataSet.heartbeatWatchdogTimer = undefined;
1405
1701
  }
1406
1702
 
1703
+ // dataset-level heartbeatIntervalMs takes priority; fall back to top-level common value
1704
+ const heartbeatIntervalMs = dataSet?.heartbeatIntervalMs ?? this.topLevelHeartbeatIntervalMs;
1705
+
1706
+ if (!dataSet?.hashTree || !heartbeatIntervalMs) {
1707
+ // eslint-disable-next-line no-continue
1708
+ continue;
1709
+ }
1710
+
1407
1711
  const backoffTime = this.getWeightedBackoffTime(dataSet.backoff);
1408
- const delay = this.heartbeatIntervalMs + backoffTime;
1712
+ const delay = heartbeatIntervalMs + backoffTime;
1409
1713
 
1410
- dataSet.heartbeatWatchdogTimer = setTimeout(async () => {
1714
+ dataSet.heartbeatWatchdogTimer = setTimeout(() => {
1411
1715
  dataSet.heartbeatWatchdogTimer = undefined;
1412
1716
 
1413
1717
  LoggerProxy.logger.warn(
1414
1718
  `HashTreeParser#resetHeartbeatWatchdogs --> ${this.debugId} Heartbeat watchdog fired for data set "${dataSet.name}" - no heartbeat received within expected interval, initiating sync`
1415
1719
  );
1416
1720
 
1417
- await this.performSync(
1418
- dataSet,
1419
- dataSet.hashTree.getRootHash(),
1420
- `heartbeat watchdog expired`
1421
- );
1721
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.HASH_TREE_HEARTBEAT_WATCHDOG_EXPIRED, {
1722
+ debugId: this.debugId,
1723
+ dataSetName: dataSet.name,
1724
+ });
1725
+
1726
+ this.enqueueSyncForDataset(dataSet.name, `heartbeat watchdog expired`);
1727
+ this.resetHeartbeatWatchdogs([dataSet]);
1422
1728
  }, delay);
1423
1729
  }
1424
1730
  }
@@ -1451,7 +1757,13 @@ class HashTreeParser {
1451
1757
  `HashTreeParser#stop --> ${this.debugId} Stopping HashTreeParser, clearing timers and hash trees`
1452
1758
  );
1453
1759
  this.stopAllTimers();
1760
+ this.syncQueue = [];
1761
+ this.topLevelHeartbeatIntervalMs = undefined;
1762
+ this.syncAllBackoffType = SyncAllBackoffType.NONE;
1763
+ this.dataSetsSyncedDuringBackoff = new Set();
1454
1764
  Object.values(this.dataSets).forEach((dataSet) => {
1765
+ dataSet.syncAbortController?.abort();
1766
+ dataSet.syncAbortController = undefined;
1455
1767
  dataSet.hashTree = undefined;
1456
1768
  });
1457
1769
  this.visibleDataSets = [];
@@ -1459,17 +1771,27 @@ class HashTreeParser {
1459
1771
  }
1460
1772
 
1461
1773
  /**
1462
- * Resumes the HashTreeParser that was previously stopped.
1774
+ * Cleans up the HashTreeParser, stopping all timers and clearing all internal state.
1775
+ * After calling this, the parser should not be used anymore.
1776
+ * @returns {void}
1777
+ */
1778
+ public cleanUp() {
1779
+ this.stop();
1780
+ this.dataSets = {};
1781
+ }
1782
+
1783
+ /**
1784
+ * Resumes the HashTreeParser that was previously stopped, using a hash tree message.
1463
1785
  * @param {HashTreeMessage} message - The message to resume with, it must contain metadata with visible data sets info
1464
1786
  * @returns {void}
1465
1787
  */
1466
- public resume(message: HashTreeMessage) {
1788
+ public resumeFromMessage(message: HashTreeMessage) {
1467
1789
  // check that message contains metadata with visible data sets - this is essential to be able to resume
1468
1790
  const metadataObject = message.locusStateElements?.find((el) => isMetadata(el));
1469
1791
 
1470
1792
  if (!metadataObject?.data?.visibleDataSets) {
1471
1793
  LoggerProxy.logger.warn(
1472
- `HashTreeParser#resume --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1794
+ `HashTreeParser#resumeFromMessage --> ${this.debugId} Cannot resume HashTreeParser because the message is missing metadata with visible data sets info`
1473
1795
  );
1474
1796
 
1475
1797
  return;
@@ -1490,7 +1812,7 @@ class HashTreeParser {
1490
1812
  };
1491
1813
  }
1492
1814
  LoggerProxy.logger.info(
1493
- `HashTreeParser#resume --> ${
1815
+ `HashTreeParser#resumeFromMessage --> ${
1494
1816
  this.debugId
1495
1817
  } Resuming HashTreeParser with data sets: ${Object.keys(this.dataSets).join(
1496
1818
  ', '
@@ -1501,18 +1823,47 @@ class HashTreeParser {
1501
1823
  this.handleMessage(message, 'on resume');
1502
1824
  }
1503
1825
 
1826
+ /**
1827
+ * Resumes the HashTreeParser that was previously stopped, using a Locus API response.
1828
+ * Unlike resumeFromMessage(), this does not require metadata/dataSets in the input,
1829
+ * as it fetches all necessary information from Locus via initializeFromGetLociResponse.
1830
+ * @param {LocusDTO} locus - locus object from an API response
1831
+ * @returns {Promise}
1832
+ */
1833
+ public async resumeFromApiResponse(locus: LocusDTO) {
1834
+ this.state = 'active';
1835
+ this.dataSets = {};
1836
+
1837
+ LoggerProxy.logger.info(
1838
+ `HashTreeParser#resumeFromApiResponse --> ${this.debugId} Resuming HashTreeParser from API response`
1839
+ );
1840
+
1841
+ await this.initializeFromGetLociResponse(locus);
1842
+ }
1843
+
1504
1844
  private checkForSentinelHttpResponse(error: any, dataSetName?: string) {
1845
+ // 404 for any dataset means the locus is no longer available at this URL - could be replaced or ended
1846
+ // if a dataset is just not visible, we would get a 400
1847
+ if (error.statusCode === 404) {
1848
+ LoggerProxy.logger.info(
1849
+ `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received 404 for data set "${dataSetName}", locus not found`
1850
+ );
1851
+ this.stopAllTimers();
1852
+
1853
+ throw new LocusNotFoundError();
1854
+ }
1855
+
1505
1856
  const isValidDataSetForSentinel =
1506
1857
  dataSetName === undefined ||
1507
1858
  PossibleSentinelMessageDataSetNames.includes(dataSetName.toLowerCase());
1508
1859
 
1509
1860
  if (
1510
- ((error.statusCode === 409 && error.body?.errorCode === 2403004) ||
1511
- error.statusCode === 404) &&
1861
+ error.statusCode === 409 &&
1862
+ error.body?.errorCode === LocusErrorCodes.LOCUS_INACTIVE &&
1512
1863
  isValidDataSetForSentinel
1513
1864
  ) {
1514
1865
  LoggerProxy.logger.info(
1515
- `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received ${error.statusCode} for data set "${dataSetName}", indicating that the meeting has ended`
1866
+ `HashTreeParser#checkForSentinelHttpResponse --> ${this.debugId} Received ${error.statusCode}/${error.body?.errorCode} for data set "${dataSetName}", indicating that the meeting has ended`
1516
1867
  );
1517
1868
  this.stopAllTimers();
1518
1869
 
@@ -1524,7 +1875,7 @@ class HashTreeParser {
1524
1875
  * Gets the current hashes from the locus for a specific data set.
1525
1876
  * @param {string} dataSetName
1526
1877
  * @param {string} currentRootHash
1527
- * @returns {string[]}
1878
+ * @returns {Object|null} An object containing the hashes and leaf count, or null if the hashes match and no sync is needed
1528
1879
  */
1529
1880
  private getHashesFromLocus(dataSetName: string, currentRootHash: string) {
1530
1881
  LoggerProxy.logger.info(
@@ -1543,6 +1894,15 @@ class HashTreeParser {
1543
1894
  },
1544
1895
  })
1545
1896
  .then((response) => {
1897
+ if (!response.body || isEmpty(response.body)) {
1898
+ // 204 with empty body means our hashes match Locus, no sync needed
1899
+ LoggerProxy.logger.info(
1900
+ `HashTreeParser#getHashesFromLocus --> ${this.debugId} Got ${response.statusCode} with empty body for data set "${dataSetName}", hashes match - no sync needed`
1901
+ );
1902
+
1903
+ return null;
1904
+ }
1905
+
1546
1906
  const hashes = response.body?.hashes as string[] | undefined;
1547
1907
  const dataSetFromResponse = response.body?.dataSet;
1548
1908
 
@@ -1571,6 +1931,13 @@ class HashTreeParser {
1571
1931
  error
1572
1932
  );
1573
1933
  this.checkForSentinelHttpResponse(error, dataSet.name);
1934
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
1935
+ debugId: this.debugId,
1936
+ dataSetName,
1937
+ request: 'GET /hashtree',
1938
+ statusCode: error.statusCode,
1939
+ reason: error.message,
1940
+ });
1574
1941
 
1575
1942
  throw error;
1576
1943
  });
@@ -1580,29 +1947,43 @@ class HashTreeParser {
1580
1947
  * Sends a sync request to Locus for the specified data set.
1581
1948
  *
1582
1949
  * @param {InternalDataSet} dataSet The data set to sync.
1583
- * @param {Record<number, LeafDataItem[]>} mismatchedLeavesData The mismatched leaves data to include in the sync request.
1950
+ * @param {Object} options Either `{ isInitialization: true }` for init syncs (uses leafCount=1 with empty leaf data) or `{ mismatchedLeavesData }` for normal syncs.
1584
1951
  * @returns {Promise<HashTreeMessage|null>}
1585
1952
  */
1586
1953
  private sendSyncRequestToLocus(
1587
1954
  dataSet: InternalDataSet,
1588
- mismatchedLeavesData: Record<number, LeafDataItem[]>
1955
+ options: {isInitialization: true} | {mismatchedLeavesData: Record<number, LeafDataItem[]>}
1589
1956
  ): Promise<HashTreeMessage | null> {
1590
1957
  LoggerProxy.logger.info(
1591
1958
  `HashTreeParser#sendSyncRequestToLocus --> ${this.debugId} Sending sync request for data set "${dataSet.name}"`
1592
1959
  );
1593
1960
 
1961
+ const isInitialization = 'isInitialization' in options;
1962
+
1594
1963
  const url = `${dataSet.url}/sync`;
1595
- const body = {
1596
- leafCount: dataSet.leafCount,
1964
+ const body: {
1965
+ leafCount: number;
1966
+ leafDataEntries: {leafIndex: number; elementIds: LeafDataItem[]}[];
1967
+ } = {
1968
+ leafCount: isInitialization ? 1 : dataSet.leafCount,
1597
1969
  leafDataEntries: [],
1598
1970
  };
1599
1971
 
1600
- Object.keys(mismatchedLeavesData).forEach((index) => {
1601
- body.leafDataEntries.push({
1602
- leafIndex: parseInt(index, 10),
1603
- elementIds: mismatchedLeavesData[index],
1972
+ if (isInitialization) {
1973
+ // initialization sync: Locus requires leafCount=1 with a single empty leaf
1974
+ body.leafDataEntries.push({leafIndex: 0, elementIds: []});
1975
+ } else {
1976
+ const {mismatchedLeavesData} = options;
1977
+
1978
+ Object.keys(mismatchedLeavesData).forEach((index) => {
1979
+ const leafIndex = parseInt(index, 10);
1980
+
1981
+ body.leafDataEntries.push({
1982
+ leafIndex,
1983
+ elementIds: mismatchedLeavesData[leafIndex],
1984
+ });
1604
1985
  });
1605
- });
1986
+ }
1606
1987
 
1607
1988
  const ourCurrentRootHash = dataSet.hashTree ? dataSet.hashTree.getRootHash() : EMPTY_HASH;
1608
1989
 
@@ -1615,10 +1996,6 @@ class HashTreeParser {
1615
1996
  body,
1616
1997
  })
1617
1998
  .then((resp) => {
1618
- LoggerProxy.logger.info(
1619
- `HashTreeParser#sendSyncRequestToLocus --> ${this.debugId} Sync request succeeded for "${dataSet.name}"`
1620
- );
1621
-
1622
1999
  if (!resp.body || isEmpty(resp.body)) {
1623
2000
  LoggerProxy.logger.info(
1624
2001
  `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 +2012,13 @@ class HashTreeParser {
1635
2012
  error
1636
2013
  );
1637
2014
  this.checkForSentinelHttpResponse(error, dataSet.name);
2015
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.HASH_TREE_SYNC_FAILURE, {
2016
+ debugId: this.debugId,
2017
+ dataSetName: dataSet.name,
2018
+ request: 'POST /sync',
2019
+ statusCode: error.statusCode,
2020
+ reason: error.message,
2021
+ });
1638
2022
 
1639
2023
  throw error;
1640
2024
  });